initial commit

This commit is contained in:
Daniel Noulet 2026-10-07 15:14:50 +02:00
commit 1253955070
75 changed files with 13972 additions and 0 deletions

View file

@ -0,0 +1,113 @@
// 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) {}