// 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 }