initial commit
This commit is contained in:
commit
1253955070
75 changed files with 13972 additions and 0 deletions
513
api/internal/transport/schema.go
Normal file
513
api/internal/transport/schema.go
Normal file
|
|
@ -0,0 +1,513 @@
|
|||
// 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue