// Package service holds the business logic. It coordinates the store, the blob // store, the analyzer and the event publisher, and it is the only layer that // knows how the application's features fit together. package service import ( "context" "errors" "fmt" "go-places-api/internal/analysis" "go-places-api/internal/blobstore" "go-places-api/internal/config" "go-places-api/internal/events" "go-places-api/internal/store" ) // Errors surfaced to the transport layer. The HTTP and GraphQL layers map these // onto status codes and GraphQL error extensions. var ( // ErrNotFound means the addressed entity does not exist. ErrNotFound = errors.New("not found") // ErrInvalidInput means the request was malformed or violated a rule. ErrInvalidInput = errors.New("invalid input") // ErrTooLarge means the upload exceeded the configured limit. ErrTooLarge = errors.New("upload too large") // ErrUnsupportedType means the upload's content type is not allowed. ErrUnsupportedType = errors.New("unsupported media type") ) // Service is the application entry point used by the transport layers. type Service struct { store store.Store blobs blobstore.BlobStore analyze analysis.Analyzer events events.Publisher cfg config.Config logger Logger } // Logger is the small logging surface the service needs. Keeping it narrow // means tests can pass a no-op without pulling in slog. type Logger interface { Warn(msg string, args ...any) Info(msg string, args ...any) } // Options configures a Service. type Options struct { Store store.Store Blobs blobstore.BlobStore Analyzer analysis.Analyzer Events events.Publisher Config config.Config Logger Logger } // New builds a Service, filling in no-op collaborators where none are supplied // so a partially wired service still works in tests. func New(opts Options) *Service { s := &Service{ store: opts.Store, blobs: opts.Blobs, analyze: opts.Analyzer, events: opts.Events, cfg: opts.Config, logger: opts.Logger, } if s.analyze == nil { s.analyze = analysis.NewLocal(analysis.DefaultMaxDimension) } if s.events == nil { s.events = events.NewLogPublisher(nil) } if s.logger == nil { s.logger = nopLogger{} } return s } // Pinger is implemented by stores that can report whether they are reachable. // It is checked by the readiness probe, and is optional so an in-memory fake // still satisfies the contract without implementing it. type Pinger interface { PingContext(ctx context.Context) error } // Ping reports whether the service's dependencies can serve traffic. A store // that cannot report its health is treated as healthy. func (s *Service) Ping(ctx context.Context) error { p, ok := s.store.(Pinger) if !ok { return nil } return p.PingContext(ctx) } // mapStoreError translates store sentinels into service sentinels. func mapStoreError(err error, entity string) error { switch { case errors.Is(err, store.ErrNotFound): return fmt.Errorf("%w: no such %s", ErrNotFound, entity) case errors.Is(err, store.ErrConflict): return fmt.Errorf("%w: conflicting %s", ErrInvalidInput, entity) default: return err } } type nopLogger struct{} func (nopLogger) Warn(string, ...any) {} func (nopLogger) Info(string, ...any) {}