514 lines
18 KiB
Go
514 lines
18 KiB
Go
|
|
// 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
|
||
|
|
}
|