go-places/api/internal/transport/schema.go

514 lines
18 KiB
Go
Raw Normal View History

2026-10-07 15:14:50 +02:00
// Package transport exposes the service over HTTP: a GraphQL endpoint for the
// data, a multipart endpoint for picture uploads, read-only serving of stored
// media, and the health probes the Kubernetes charts poll.
package transport
import (
"context"
"errors"
"github.com/graphql-go/graphql"
"go-places-api/internal/auth"
"go-places-api/internal/domain"
"go-places-api/internal/service"
"go-places-api/internal/store"
)
// The GraphQL type set mirrors the domain structs. Field names line up with the
// `json` tags on the domain models, so graphql-go's default resolver already
// populates most fields; explicit resolvers are only needed where a value has to
// be computed (image URLs, linked pictures, related places) or where the raw
// domain type needs converting (AnalysisStatus, LinkRadiusMeters).
//
// Image, Place and RelatedPlace refer to each other, so their fields are built
// by thunks that graphql-go evaluates lazily. Declaring them eagerly would mean
// a type had to exist before it was defined.
var (
badgeType = graphql.NewObject(graphql.ObjectConfig{
Name: "Badge",
Description: "A recognisable attribute detected in a picture.",
Fields: graphql.Fields{
"label": &graphql.Field{Type: graphql.NewNonNull(graphql.String)},
"display": &graphql.Field{Type: graphql.String},
"confidence": &graphql.Field{Type: graphql.NewNonNull(graphql.Float)},
"source": &graphql.Field{Type: graphql.String},
},
})
coordinateType = graphql.NewObject(graphql.ObjectConfig{
Name: "Coordinate",
Description: "A WGS-84 geographic point.",
Fields: graphql.Fields{
"latitude": &graphql.Field{Type: graphql.NewNonNull(graphql.Float)},
"longitude": &graphql.Field{Type: graphql.NewNonNull(graphql.Float)},
},
})
userType = graphql.NewObject(graphql.ObjectConfig{
Name: "User",
Description: "A logged-in user, as asserted by the go-loose SSO service.",
Fields: graphql.Fields{
"id": &graphql.Field{Type: graphql.NewNonNull(graphql.String)},
"username": &graphql.Field{Type: graphql.String},
"email": &graphql.Field{Type: graphql.String},
},
})
commentType = graphql.NewObject(graphql.ObjectConfig{
Name: "Comment",
Description: "A user reaction on a place.",
Fields: graphql.Fields{
"id": &graphql.Field{Type: graphql.NewNonNull(graphql.String)},
"placeId": &graphql.Field{Type: graphql.String},
"content": &graphql.Field{Type: graphql.NewNonNull(graphql.String)},
"creator": &graphql.Field{Type: graphql.NewNonNull(userType)},
"createdAt": &graphql.Field{Type: graphql.NewNonNull(graphql.DateTime)},
},
})
// PlaceInput is the createPlace argument. Latitude and longitude are
// optional so an omitted coordinate can be told apart from the origin, which
// is a legitimate point.
placeInput = graphql.NewInputObject(graphql.InputObjectConfig{
Name: "PlaceInput",
Fields: graphql.InputObjectConfigFieldMap{
"title": &graphql.InputObjectFieldConfig{Type: graphql.NewNonNull(graphql.String)},
"description": &graphql.InputObjectFieldConfig{Type: graphql.String},
"latitude": &graphql.InputObjectFieldConfig{Type: graphql.Float},
"longitude": &graphql.InputObjectFieldConfig{Type: graphql.Float},
"linkRadiusMeters": &graphql.InputObjectFieldConfig{Type: graphql.Float},
"caption": &graphql.InputObjectFieldConfig{Type: graphql.String},
},
})
)
// Image, Place and RelatedPlace refer to each other, so each type is created
// once and cached on the builder before any field thunk runs. Building the type
// objects as thunks keeps the references valid: graphql-go evaluates a thunk the
// first time it needs the type's field map, which happens while NewSchema walks
// the type graph, by which point all three instances already exist.
//
// The builder is used exactly once, during startup, before the server serves any
// request, so the lazy caching needs no further synchronisation.
type builder struct {
svc *service.Service
image *graphql.Object
place *graphql.Object
related *graphql.Object
}
// imageType returns the shared Image type.
func (b *builder) imageType() *graphql.Object {
if b.image == nil {
b.image = graphql.NewObject(graphql.ObjectConfig{
Name: "Image",
Description: "A picture attached to a place.",
Fields: graphql.FieldsThunk(func() graphql.Fields {
return graphql.Fields{
"id": &graphql.Field{Type: graphql.NewNonNull(graphql.String)},
"placeId": &graphql.Field{Type: graphql.String},
"url": &graphql.Field{Type: graphql.String, Resolve: nullOnError(resolveImageURL(b.svc))},
"caption": &graphql.Field{Type: graphql.String},
"contentType": &graphql.Field{Type: graphql.String},
"byteSize": &graphql.Field{Type: graphql.NewNonNull(graphql.Int)},
"width": &graphql.Field{Type: graphql.Int},
"height": &graphql.Field{Type: graphql.Int},
"coordinate": &graphql.Field{Type: coordinateType},
"takenAt": &graphql.Field{Type: graphql.DateTime},
"cameraMake": &graphql.Field{Type: graphql.String},
"cameraModel": &graphql.Field{Type: graphql.String},
"badges": &graphql.Field{Type: graphql.NewList(badgeType)},
"analysisStatus": &graphql.Field{
Type: graphql.String,
Resolve: func(p graphql.ResolveParams) (interface{}, error) {
img, ok := p.Source.(domain.Image)
if !ok {
return nil, nil
}
return string(img.AnalysisStatus), nil
},
},
"analysisError": &graphql.Field{Type: graphql.String},
// LinkedImages is derived on read, so it is only ever computed
// when a client actually selects it.
"linkedImages": &graphql.Field{
Type: graphql.NewList(b.imageType()),
Resolve: nullOnError(resolveLinkedImages(b.svc)),
},
"createdAt": &graphql.Field{Type: graphql.NewNonNull(graphql.DateTime)},
}
}),
})
}
return b.image
}
// placeType returns the shared Place type.
func (b *builder) placeType() *graphql.Object {
if b.place == nil {
b.place = graphql.NewObject(graphql.ObjectConfig{
Name: "Place",
Description: "A location the user visited and shared.",
Fields: graphql.FieldsThunk(func() graphql.Fields {
return graphql.Fields{
"id": &graphql.Field{Type: graphql.NewNonNull(graphql.String)},
"title": &graphql.Field{Type: graphql.NewNonNull(graphql.String)},
"description": &graphql.Field{Type: graphql.String},
"coordinate": &graphql.Field{Type: coordinateType},
"linkRadiusMeters": &graphql.Field{
Type: graphql.Float,
Resolve: func(p graphql.ResolveParams) (interface{}, error) {
pl, ok := p.Source.(domain.Place)
if !ok {
return nil, nil
}
return pl.LinkRadiusMeters, nil
},
},
"creator": &graphql.Field{Type: graphql.NewNonNull(userType)},
"images": &graphql.Field{Type: graphql.NewList(b.imageType())},
"comments": &graphql.Field{Type: graphql.NewList(commentType)},
"relatedPlaces": &graphql.Field{
Type: graphql.NewList(b.relatedPlaceType()),
Resolve: nullOnError(resolveRelatedPlaces(b.svc)),
},
"createdAt": &graphql.Field{Type: graphql.NewNonNull(graphql.DateTime)},
}
}),
})
}
return b.place
}
// relatedPlaceType returns the shared RelatedPlace type.
func (b *builder) relatedPlaceType() *graphql.Object {
if b.related == nil {
b.related = graphql.NewObject(graphql.ObjectConfig{
Name: "RelatedPlace",
Description: "A place whose pictures overlap the queried one.",
Fields: graphql.FieldsThunk(func() graphql.Fields {
return graphql.Fields{
"place": &graphql.Field{Type: graphql.NewNonNull(b.placeType())},
"distanceMeters": &graphql.Field{Type: graphql.NewNonNull(graphql.Float)},
"sharedImageIds": &graphql.Field{Type: graphql.NewList(graphql.String)},
}
}),
})
}
return b.related
}
// newSchema assembles the executable schema.
func newSchema(svc *service.Service) (graphql.Schema, error) {
b := &builder{svc: svc}
// Instantiated up front so the thunks above observe cached instances rather
// than building a second copy of each type.
imageType, placeType, relatedType := b.imageType(), b.placeType(), b.relatedPlaceType()
query := graphql.NewObject(graphql.ObjectConfig{
Name: "Query",
Description: "Read access to the travel log.",
Fields: graphql.FieldsThunk(func() graphql.Fields {
return graphql.Fields{
"place": &graphql.Field{
Type: placeType,
Description: "One place by identifier.",
Args: graphql.FieldConfigArgument{
"id": &graphql.ArgumentConfig{Type: graphql.NewNonNull(graphql.String)},
},
Resolve: nullOnError(resolvePlace(b.svc)),
},
"places": &graphql.Field{
Type: graphql.NewList(placeType),
Description: "Places, newest first.",
Args: graphql.FieldConfigArgument{
"limit": &graphql.ArgumentConfig{Type: graphql.Int, DefaultValue: 20},
"offset": &graphql.ArgumentConfig{Type: graphql.Int, DefaultValue: 0},
},
Resolve: nullOnError(resolvePlaces(b.svc)),
},
"images": &graphql.Field{
Type: graphql.NewList(imageType),
Description: "Pictures, newest first.",
Args: graphql.FieldConfigArgument{
"placeId": &graphql.ArgumentConfig{Type: graphql.String},
"creatorId": &graphql.ArgumentConfig{Type: graphql.String},
"limit": &graphql.ArgumentConfig{Type: graphql.Int, DefaultValue: 20},
"offset": &graphql.ArgumentConfig{Type: graphql.Int, DefaultValue: 0},
},
Resolve: nullOnError(resolveImages(b.svc)),
},
"relatedPlaces": &graphql.Field{
Type: graphql.NewList(relatedType),
Description: "Places whose pictures overlap the given one.",
Args: graphql.FieldConfigArgument{
"placeId": &graphql.ArgumentConfig{Type: graphql.NewNonNull(graphql.String)},
},
Resolve: nullOnError(resolveRelatedByID(b.svc)),
},
}
}),
})
mutation := graphql.NewObject(graphql.ObjectConfig{
Name: "Mutation",
Description: "Write access to the travel log.",
Fields: graphql.FieldsThunk(func() graphql.Fields {
return graphql.Fields{
"createPlace": &graphql.Field{
Type: placeType,
Args: graphql.FieldConfigArgument{
"input": &graphql.ArgumentConfig{Type: graphql.NewNonNull(placeInput)},
},
Resolve: nullOnError(resolveCreatePlace(b.svc)),
},
"addComment": &graphql.Field{
Type: commentType,
Args: graphql.FieldConfigArgument{
"placeId": &graphql.ArgumentConfig{Type: graphql.NewNonNull(graphql.String)},
"content": &graphql.ArgumentConfig{Type: graphql.NewNonNull(graphql.String)},
},
Resolve: nullOnError(resolveAddComment(b.svc)),
},
"updateImageCaption": &graphql.Field{
Type: imageType,
Args: graphql.FieldConfigArgument{
"imageId": &graphql.ArgumentConfig{Type: graphql.NewNonNull(graphql.String)},
"caption": &graphql.ArgumentConfig{Type: graphql.NewNonNull(graphql.String)},
},
Resolve: nullOnError(resolveUpdateImageCaption(b.svc)),
},
"deletePlace": &graphql.Field{
Type: graphql.Boolean,
Description: "Remove a place and everything attached to it.",
Args: graphql.FieldConfigArgument{
"id": &graphql.ArgumentConfig{Type: graphql.NewNonNull(graphql.String)},
},
Resolve: nullOnError(resolveDeletePlace(b.svc)),
},
"deleteImage": &graphql.Field{
Type: graphql.Boolean,
Description: "Remove a picture and its stored bytes.",
Args: graphql.FieldConfigArgument{
"id": &graphql.ArgumentConfig{Type: graphql.NewNonNull(graphql.String)},
},
Resolve: nullOnError(resolveDeleteImage(b.svc)),
},
}
}),
})
return graphql.NewSchema(graphql.SchemaConfig{
Query: query,
Mutation: mutation,
})
}
// ---------------------------------------------------------------- resolvers ---
//
// Each resolver is written as a typed function and wrapped by nullOnError, so a
// failure nulls the field instead of emitting the zero value of the domain type.
// graphql-go uses whatever a resolver returns, error or not, so returning the
// zero struct alongside an error would hand clients a plausible-looking empty
// record alongside the error.
// nullOnError adapts a typed resolver to a GraphQL field resolver, reporting a
// failure as a null field.
func nullOnError[T any](fn func(graphql.ResolveParams) (T, error)) graphql.FieldResolveFn {
return func(p graphql.ResolveParams) (interface{}, error) {
v, err := fn(p)
if err != nil {
return nil, err
}
return v, nil
}
}
func resolvePlace(svc *service.Service) func(graphql.ResolveParams) (domain.Place, error) {
return func(p graphql.ResolveParams) (domain.Place, error) {
return svc.GetPlace(p.Context, p.Args["id"].(string))
}
}
func resolvePlaces(svc *service.Service) func(graphql.ResolveParams) ([]domain.Place, error) {
return func(p graphql.ResolveParams) ([]domain.Place, error) {
limit, offset := pageArgs(p.Args)
return svc.ListPlaces(p.Context, limit, offset)
}
}
func resolveImages(svc *service.Service) func(graphql.ResolveParams) ([]domain.Image, error) {
return func(p graphql.ResolveParams) ([]domain.Image, error) {
limit, offset := pageArgs(p.Args)
f := store.ImageFilter{Limit: limit, Offset: offset}
if v, ok := p.Args["placeId"].(string); ok {
f.PlaceID = v
}
if v, ok := p.Args["creatorId"].(string); ok {
f.CreatorID = v
}
return svc.ListImages(p.Context, f)
}
}
func resolveRelatedByID(svc *service.Service) func(graphql.ResolveParams) ([]domain.RelatedPlace, error) {
return func(p graphql.ResolveParams) ([]domain.RelatedPlace, error) {
return svc.RelatedPlaces(p.Context, p.Args["placeId"].(string))
}
}
func resolveCreatePlace(svc *service.Service) func(graphql.ResolveParams) (domain.Place, error) {
return func(p graphql.ResolveParams) (domain.Place, error) {
author, err := requireUser(p.Context)
if err != nil {
return domain.Place{}, err
}
raw, _ := p.Args["input"].(map[string]interface{})
return svc.CreatePlace(p.Context, author, decodePlaceInput(raw))
}
}
func resolveAddComment(svc *service.Service) func(graphql.ResolveParams) (domain.Comment, error) {
return func(p graphql.ResolveParams) (domain.Comment, error) {
author, err := requireUser(p.Context)
if err != nil {
return domain.Comment{}, err
}
return svc.AddComment(p.Context, author, p.Args["placeId"].(string), p.Args["content"].(string))
}
}
func resolveUpdateImageCaption(svc *service.Service) func(graphql.ResolveParams) (domain.Image, error) {
return func(p graphql.ResolveParams) (domain.Image, error) {
return svc.UpdateImageCaption(p.Context, p.Args["imageId"].(string), p.Args["caption"].(string))
}
}
func resolveDeletePlace(svc *service.Service) func(graphql.ResolveParams) (bool, error) {
return func(p graphql.ResolveParams) (bool, error) {
if err := svc.DeletePlace(p.Context, p.Args["id"].(string)); err != nil {
return false, err
}
return true, nil
}
}
func resolveDeleteImage(svc *service.Service) func(graphql.ResolveParams) (bool, error) {
return func(p graphql.ResolveParams) (bool, error) {
if err := svc.DeleteImage(p.Context, p.Args["id"].(string)); err != nil {
return false, err
}
return true, nil
}
}
// resolveImageURL turns the private storage key into the public URL. The key
// itself is never exposed.
func resolveImageURL(svc *service.Service) func(graphql.ResolveParams) (string, error) {
return func(p graphql.ResolveParams) (string, error) {
img, ok := p.Source.(domain.Image)
if !ok {
return "", nil
}
return svc.ImageURL(img.StorageKey), nil
}
}
// resolveLinkedImages hydrates the neighbours discovered by the linking pass. The
// links are already ordered by distance, so the order is preserved.
func resolveLinkedImages(svc *service.Service) func(graphql.ResolveParams) ([]domain.Image, error) {
return func(p graphql.ResolveParams) ([]domain.Image, error) {
img, ok := p.Source.(domain.Image)
if !ok {
return nil, nil
}
links, err := svc.ImageLinks(p.Context, img.ID)
if err != nil {
return nil, err
}
out := make([]domain.Image, 0, len(links))
for _, link := range links {
// Each neighbour is re-read so the nested selection sees its badges
// and URL just like a top-level picture.
related, err := svc.ImageByID(p.Context, link.RelatedImageID)
if err != nil {
// A neighbour that vanished between the two reads is skipped
// rather than failing the whole selection.
continue
}
out = append(out, related)
}
return out, nil
}
}
func resolveRelatedPlaces(svc *service.Service) func(graphql.ResolveParams) ([]domain.RelatedPlace, error) {
return func(p graphql.ResolveParams) ([]domain.RelatedPlace, error) {
place, ok := p.Source.(domain.Place)
if !ok {
return nil, nil
}
related, err := svc.RelatedPlaces(p.Context, place.ID)
if err != nil {
return nil, err
}
if related == nil {
// An empty list rather than null, so clients can iterate safely.
return []domain.RelatedPlace{}, nil
}
return related, nil
}
}
// ------------------------------------------------------------------ helpers ---
// requireUser pulls the verified identity that the auth middleware placed on the
// context. Reaching this point without one means a route was mounted without the
// middleware, which is a wiring bug rather than a client error.
func requireUser(ctx context.Context) (domain.User, error) {
u, ok := auth.FromContext(ctx)
if !ok {
return domain.User{}, errors.New("transport: request is not authenticated")
}
return u.User, nil
}
// pageArgs reads the shared limit/offset arguments, tolerating their absence.
func pageArgs(args map[string]interface{}) (limit, offset int) {
if v, ok := args["limit"].(int); ok {
limit = v
}
if v, ok := args["offset"].(int); ok {
offset = v
}
return limit, offset
}
// decodePlaceInput converts the GraphQL input object into the service payload,
// turning absent numeric fields into nil pointers.
func decodePlaceInput(raw map[string]interface{}) domain.PlaceInput {
var in domain.PlaceInput
if v, ok := raw["title"].(string); ok {
in.Title = v
}
if v, ok := raw["description"].(string); ok {
in.Description = v
}
if v, ok := raw["caption"].(string); ok {
in.Caption = v
}
if v, ok := raw["latitude"].(float64); ok {
in.Latitude = &v
}
if v, ok := raw["longitude"].(float64); ok {
in.Longitude = &v
}
if v, ok := raw["linkRadiusMeters"].(float64); ok {
in.LinkRadiusMeters = &v
}
return in
}