Add APIs for geocoding and reverse-geocoding
This commit is contained in:
parent
437f87013a
commit
43dce16fbd
7 changed files with 175 additions and 40 deletions
|
|
@ -24,11 +24,12 @@ type ErrorAPI struct {
|
||||||
var decoder = schema.NewDecoder()
|
var decoder = schema.NewDecoder()
|
||||||
|
|
||||||
type handlerFunctionDelete func(context.Context, *http.Request, platform.User) *nhttp.ErrorWithStatus
|
type handlerFunctionDelete func(context.Context, *http.Request, platform.User) *nhttp.ErrorWithStatus
|
||||||
type handlerFunctionGet[T any] func(context.Context, *http.Request, platform.User, resource.QueryParams) (*T, *nhttp.ErrorWithStatus)
|
type handlerFunctionGet[T any] func(context.Context, *http.Request, resource.QueryParams) (*T, *nhttp.ErrorWithStatus)
|
||||||
|
type handlerFunctionGetAuthenticated[T any] func(context.Context, *http.Request, platform.User, resource.QueryParams) (*T, *nhttp.ErrorWithStatus)
|
||||||
type handlerFunctionGetImage func(context.Context, *http.Request, platform.User) (file.Collection, uuid.UUID, *nhttp.ErrorWithStatus)
|
type handlerFunctionGetImage func(context.Context, *http.Request, platform.User) (file.Collection, uuid.UUID, *nhttp.ErrorWithStatus)
|
||||||
type handlerFunctionGetSlice[T any] func(context.Context, *http.Request, resource.QueryParams) ([]*T, *nhttp.ErrorWithStatus)
|
type handlerFunctionGetSlice[T any] func(context.Context, *http.Request, resource.QueryParams) ([]*T, *nhttp.ErrorWithStatus)
|
||||||
type handlerFunctionGetSliceAuthenticated[T any] func(context.Context, *http.Request, platform.User, resource.QueryParams) ([]*T, *nhttp.ErrorWithStatus)
|
type handlerFunctionGetSliceAuthenticated[T any] func(context.Context, *http.Request, platform.User, resource.QueryParams) ([]*T, *nhttp.ErrorWithStatus)
|
||||||
type handlerFunctionPost[RequestType any] func(context.Context, *http.Request, RequestType) (string, *nhttp.ErrorWithStatus)
|
type handlerFunctionPost[RequestType any, ResponseType any] func(context.Context, *http.Request, RequestType) (ResponseType, *nhttp.ErrorWithStatus)
|
||||||
type handlerFunctionPostAuthenticated[RequestType any, ResponseType any] func(context.Context, *http.Request, platform.User, RequestType) (ResponseType, *nhttp.ErrorWithStatus)
|
type handlerFunctionPostAuthenticated[RequestType any, ResponseType any] func(context.Context, *http.Request, platform.User, RequestType) (ResponseType, *nhttp.ErrorWithStatus)
|
||||||
type handlerFunctionPostFormMultipart[RequestType any, ResponseType any] func(context.Context, *http.Request, RequestType) (*ResponseType, *nhttp.ErrorWithStatus)
|
type handlerFunctionPostFormMultipart[RequestType any, ResponseType any] func(context.Context, *http.Request, RequestType) (*ResponseType, *nhttp.ErrorWithStatus)
|
||||||
type handlerFunctionPutAuthenticated[RequestType any] func(context.Context, *http.Request, platform.User, RequestType) (string, *nhttp.ErrorWithStatus)
|
type handlerFunctionPutAuthenticated[RequestType any] func(context.Context, *http.Request, platform.User, RequestType) (string, *nhttp.ErrorWithStatus)
|
||||||
|
|
@ -58,7 +59,7 @@ func authenticatedHandlerGetImage(f handlerFunctionGetImage) http.Handler {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func authenticatedHandlerJSON[T any](f handlerFunctionGet[T]) http.Handler {
|
func authenticatedHandlerJSON[T any](f handlerFunctionGetAuthenticated[T]) http.Handler {
|
||||||
return auth.NewEnsureAuth(func(w http.ResponseWriter, r *http.Request, u platform.User) {
|
return auth.NewEnsureAuth(func(w http.ResponseWriter, r *http.Request, u platform.User) {
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
var body []byte
|
var body []byte
|
||||||
|
|
@ -189,6 +190,31 @@ func authenticatedHandlerPostMultipart[ResponseType any](f handlerFunctionPostAu
|
||||||
w.Write(body)
|
w.Write(body)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
func handlerJSON[T any](f handlerFunctionGet[T]) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
var body []byte
|
||||||
|
var params resource.QueryParams
|
||||||
|
err := decoder.Decode(¶ms, r.URL.Query())
|
||||||
|
if err != nil {
|
||||||
|
respondErrorStatus(w, nhttp.NewBadRequest("failed to decode query: %w", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp, e := f(ctx, r, params)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
//log.Info().Str("template", template).Err(e).Msg("handler done")
|
||||||
|
if e != nil {
|
||||||
|
respondErrorStatus(w, e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body, err = json.Marshal(resp)
|
||||||
|
if err != nil {
|
||||||
|
respondErrorStatus(w, nhttp.NewError("failed to marshal json: %w", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Write(body)
|
||||||
|
}
|
||||||
|
}
|
||||||
func handlerJSONSlice[T any](f handlerFunctionGetSlice[T]) http.HandlerFunc {
|
func handlerJSONSlice[T any](f handlerFunctionGetSlice[T]) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
|
|
@ -215,7 +241,7 @@ func handlerJSONSlice[T any](f handlerFunctionGetSlice[T]) http.HandlerFunc {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func handlerJSONPost[RequestType any](f handlerFunctionPost[RequestType]) http.HandlerFunc {
|
func handlerJSONPost[RequestType any, ResponseType any](f handlerFunctionPost[RequestType, ResponseType]) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
req, e := parseRequest[RequestType](r)
|
req, e := parseRequest[RequestType](r)
|
||||||
|
|
@ -224,11 +250,16 @@ func handlerJSONPost[RequestType any](f handlerFunctionPost[RequestType]) http.H
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
path, e := f(ctx, r, *req)
|
resp, e := f(ctx, r, *req)
|
||||||
if e != nil {
|
if e != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
http.Redirect(w, r, path, http.StatusFound)
|
body, err := json.Marshal(resp)
|
||||||
|
if err != nil {
|
||||||
|
respondErrorStatus(w, nhttp.NewError("failed to marshal json: %w", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Write(body)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
func handlerFormPost[RequestType any, ResponseType any](f handlerFunctionPostFormMultipart[RequestType, ResponseType]) http.HandlerFunc {
|
func handlerFormPost[RequestType any, ResponseType any](f handlerFunctionPostFormMultipart[RequestType, ResponseType]) http.HandlerFunc {
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,8 @@ func AddRoutes(r *mux.Router) {
|
||||||
district := resource.District(router)
|
district := resource.District(router)
|
||||||
r.Handle("/district", handlerJSONSlice(district.List)).Methods("GET")
|
r.Handle("/district", handlerJSONSlice(district.List)).Methods("GET")
|
||||||
geocode := resource.Geocode(router)
|
geocode := resource.Geocode(router)
|
||||||
|
r.Handle("/geocode/by-gid/{id:.*}", handlerJSON(geocode.ByGID)).Methods("GET")
|
||||||
|
r.Handle("/geocode/reverse", handlerJSONPost(geocode.Reverse)).Methods("POST")
|
||||||
r.Handle("/geocode/suggestion", handlerJSONSlice(geocode.SuggestionList)).Methods("GET")
|
r.Handle("/geocode/suggestion", handlerJSONSlice(geocode.SuggestionList)).Methods("GET")
|
||||||
|
|
||||||
//r.HandleFunc("/district", apiGetDistrict).Methods("GET")
|
//r.HandleFunc("/district", apiGetDistrict).Methods("GET")
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ import (
|
||||||
|
|
||||||
type AutocompleteResult struct {
|
type AutocompleteResult struct {
|
||||||
Detail string
|
Detail string
|
||||||
|
GID string
|
||||||
|
Layer string // 'poi', 'postalcode', 'street',
|
||||||
Locality string
|
Locality string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -29,9 +31,17 @@ func Autocomplete(ctx context.Context, org *models.Organization, address string)
|
||||||
log.Error().Str("type", r.Type).Msg("should be handled from Stadia")
|
log.Error().Str("type", r.Type).Msg("should be handled from Stadia")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
var locality string
|
||||||
|
if r.Properties.CoarseLocation != nil {
|
||||||
|
locality = *r.Properties.CoarseLocation
|
||||||
|
} else {
|
||||||
|
locality = "???"
|
||||||
|
}
|
||||||
result[i] = &AutocompleteResult{
|
result[i] = &AutocompleteResult{
|
||||||
Detail: r.Properties.Name,
|
Detail: r.Properties.Name,
|
||||||
Locality: r.Properties.Locality,
|
GID: r.Properties.GID,
|
||||||
|
Layer: r.Properties.Layer,
|
||||||
|
Locality: locality,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result, nil
|
return result, nil
|
||||||
|
|
|
||||||
47
platform/geocode/by_gid.go
Normal file
47
platform/geocode/by_gid.go
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
package geocode
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/Gleipnir-Technology/nidus-sync/h3utils"
|
||||||
|
"github.com/Gleipnir-Technology/nidus-sync/platform/types"
|
||||||
|
"github.com/Gleipnir-Technology/nidus-sync/stadia"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ByGID(ctx context.Context, gid string) (*GeocodeResult, error) {
|
||||||
|
req := stadia.RequestGeocodeByGID{
|
||||||
|
GIDs: []string{gid},
|
||||||
|
}
|
||||||
|
resp, err := client.GeocodeByGID(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("geocodebygid: %w", err)
|
||||||
|
}
|
||||||
|
if len(resp.Features) < 1 {
|
||||||
|
return nil, fmt.Errorf("no features in result")
|
||||||
|
}
|
||||||
|
feature := resp.Features[0]
|
||||||
|
location := types.Location{
|
||||||
|
Latitude: feature.Geometry.Coordinates[1],
|
||||||
|
Longitude: feature.Geometry.Coordinates[0],
|
||||||
|
}
|
||||||
|
cell, err := h3utils.GetCell(location.Longitude, location.Latitude, 15)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("latlngtocell: %w", err)
|
||||||
|
}
|
||||||
|
return &GeocodeResult{
|
||||||
|
Address: types.Address{
|
||||||
|
Country: feature.Properties.Context.ISO3166A3,
|
||||||
|
GID: feature.Properties.GID,
|
||||||
|
Locality: feature.Properties.Context.WhosOnFirst.Locality.Name,
|
||||||
|
Number: feature.Properties.AddressComponents.Number,
|
||||||
|
PostalCode: feature.Properties.AddressComponents.PostalCode,
|
||||||
|
Raw: feature.Properties.FormattedAddressLine,
|
||||||
|
Region: feature.Properties.Context.WhosOnFirst.Region.Name,
|
||||||
|
Street: feature.Properties.AddressComponents.Street,
|
||||||
|
Unit: "",
|
||||||
|
},
|
||||||
|
Cell: cell,
|
||||||
|
Location: location,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,7 @@ import (
|
||||||
|
|
||||||
type Address struct {
|
type Address struct {
|
||||||
Country string `db:"country" json:"country"`
|
Country string `db:"country" json:"country"`
|
||||||
|
GID string `db:"gid" json:"gid"`
|
||||||
Locality string `db:"locality" json:"locality"`
|
Locality string `db:"locality" json:"locality"`
|
||||||
Number string `db:"number" json:"number"`
|
Number string `db:"number" json:"number"`
|
||||||
PostalCode string `db:"postal_code" json:"postal_code"`
|
PostalCode string `db:"postal_code" json:"postal_code"`
|
||||||
|
|
|
||||||
|
|
@ -2,19 +2,38 @@ package resource
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
nhttp "github.com/Gleipnir-Technology/nidus-sync/http"
|
|
||||||
"github.com/Gleipnir-Technology/nidus-sync/platform/geocode"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
nhttp "github.com/Gleipnir-Technology/nidus-sync/http"
|
||||||
|
ngeocode "github.com/Gleipnir-Technology/nidus-sync/platform/geocode"
|
||||||
|
"github.com/Gleipnir-Technology/nidus-sync/platform/types"
|
||||||
|
"github.com/gorilla/mux"
|
||||||
//"github.com/rs/zerolog/log"
|
//"github.com/rs/zerolog/log"
|
||||||
|
"github.com/uber/h3-go/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
type geocodeR struct {
|
type geocodeR struct {
|
||||||
router *router
|
router *router
|
||||||
}
|
}
|
||||||
|
type geocode struct {
|
||||||
|
Address types.Address `json:"address"`
|
||||||
|
Cell h3.Cell `json:"cell"`
|
||||||
|
Location types.Location `json:"location"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func newGeocode(g *ngeocode.GeocodeResult) *geocode {
|
||||||
|
return &geocode{
|
||||||
|
Address: g.Address,
|
||||||
|
Cell: g.Cell,
|
||||||
|
Location: g.Location,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type geocodeSuggestion struct {
|
type geocodeSuggestion struct {
|
||||||
Detail string `json:"detail"`
|
Detail string `json:"detail"`
|
||||||
|
GID string `json:"gid"`
|
||||||
Locality string `json:"locality"`
|
Locality string `json:"locality"`
|
||||||
|
Type string `json:"type"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func Geocode(r *router) *geocodeR {
|
func Geocode(r *router) *geocodeR {
|
||||||
|
|
@ -23,11 +42,30 @@ func Geocode(r *router) *geocodeR {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (res *geocodeR) ByGID(ctx context.Context, r *http.Request, query QueryParams) (*geocode, *nhttp.ErrorWithStatus) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
gid := vars["id"]
|
||||||
|
if gid == "" {
|
||||||
|
return nil, nhttp.NewBadRequest("no id")
|
||||||
|
}
|
||||||
|
g, err := ngeocode.ByGID(ctx, gid)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nhttp.NewError("bygid: %w", err)
|
||||||
|
}
|
||||||
|
return newGeocode(g), nil
|
||||||
|
}
|
||||||
|
func (res *geocodeR) Reverse(ctx context.Context, r *http.Request, location types.Location) (*geocode, *nhttp.ErrorWithStatus) {
|
||||||
|
g, err := ngeocode.ReverseGeocode(ctx, location)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nhttp.NewError("reverse: %w", err)
|
||||||
|
}
|
||||||
|
return newGeocode(g), nil
|
||||||
|
}
|
||||||
func (res *geocodeR) SuggestionList(ctx context.Context, r *http.Request, query QueryParams) ([]*geocodeSuggestion, *nhttp.ErrorWithStatus) {
|
func (res *geocodeR) SuggestionList(ctx context.Context, r *http.Request, query QueryParams) ([]*geocodeSuggestion, *nhttp.ErrorWithStatus) {
|
||||||
if query.Query == nil {
|
if query.Query == nil {
|
||||||
return nil, nhttp.NewBadRequest("you must include a query")
|
return nil, nhttp.NewBadRequest("you must include a query")
|
||||||
}
|
}
|
||||||
completions, err := geocode.Autocomplete(ctx, nil, *query.Query)
|
completions, err := ngeocode.Autocomplete(ctx, nil, *query.Query)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nhttp.NewError("geocode: %w", err)
|
return nil, nhttp.NewError("geocode: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -35,7 +73,9 @@ func (res *geocodeR) SuggestionList(ctx context.Context, r *http.Request, query
|
||||||
for i, c := range completions {
|
for i, c := range completions {
|
||||||
result[i] = &geocodeSuggestion{
|
result[i] = &geocodeSuggestion{
|
||||||
Detail: c.Detail,
|
Detail: c.Detail,
|
||||||
|
GID: c.GID,
|
||||||
Locality: c.Locality,
|
Locality: c.Locality,
|
||||||
|
Type: c.Layer,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result, nil
|
return result, nil
|
||||||
|
|
|
||||||
|
|
@ -110,40 +110,44 @@ type GeocodeGeometry struct {
|
||||||
|
|
||||||
// GeocodeProperties contains the properties of a geocoding result
|
// GeocodeProperties contains the properties of a geocoding result
|
||||||
type GeocodeProperties struct {
|
type GeocodeProperties struct {
|
||||||
Addendum map[string]interface{} `json:"addendum,omitempty"`
|
Addendum map[string]interface{} `json:"addendum,omitempty"`
|
||||||
AddressComponents AddressComponents `json:"address_components,omitempty"`
|
AddressComponents AddressComponents `json:"address_components,omitempty"`
|
||||||
Accuracy string `json:"accuracy"` // 'point'
|
Accuracy string `json:"accuracy"` // 'point'
|
||||||
CoarseLocation *string `json:"coarse_location"` // 'Riverton, UT, USA'
|
CoarseLocation *string `json:"coarse_location"` // 'Riverton, UT, USA'
|
||||||
Confidence float64 `json:"confidence"` // 1
|
Confidence float64 `json:"confidence"` // 1
|
||||||
Context Context `json:"context,omitempty"` // bunch of stuff
|
Context Context `json:"context,omitempty"` // bunch of stuff
|
||||||
Country string `json:"country"` // 'United States'
|
Country string `json:"country"` // 'United States'
|
||||||
CountryA string `json:"country_a"` // 'USA'
|
CountryA string `json:"country_a"` // 'USA'
|
||||||
CountryCode string `json:"country_code"` // 'US'
|
CountryCode string `json:"country_code"` // 'US'
|
||||||
CountryGID string `json:"country_gid"` // 'whosonfirst:country:85633793'
|
CountryGID string `json:"country_gid"` // 'whosonfirst:country:85633793'
|
||||||
County string `json:"county"` // "Tulare County"
|
County string `json:"county"` // "Tulare County"
|
||||||
CountyA string `json:"county_a"` // 'TL'
|
CountyA string `json:"county_a"` // 'TL'
|
||||||
CountyGID string `json:"county_gid"` // 'whosonfirst:county:102082895'
|
CountyGID string `json:"county_gid"` // 'whosonfirst:county:102082895'
|
||||||
GID string `json:"gid"` // 'openaddresses:address:us/ca/tulare-addresses-county:fe9dfab3d45c4550'
|
FormattedAddressLine string `json:"formatted_address_line"` // '123 Main Street, Riverton, Utah 84065, United States of America'
|
||||||
HouseNumber string `json:"housenumber"` // '1234'
|
FormattedAddressLines []string `json:"formatted_address_lines"` // '123 Main Street', 'Riverton, Utah 84065', 'United States of America'
|
||||||
ID string `json:"id"` // us/ca/tulare-addresses-county:fe9dfab3d45c4550
|
GID string `json:"gid"` // 'openaddresses:address:us/ca/tulare-addresses-county:fe9dfab3d45c4550'
|
||||||
Label string `json:"label"` // 1234 Main St, Dinuba, CA, USA
|
HouseNumber string `json:"housenumber"` // '1234'
|
||||||
Layer string `json:"layer"` // 'address'
|
ID string `json:"id"` // us/ca/tulare-addresses-county:fe9dfab3d45c4550
|
||||||
Locality string `json:"locality"` // 'Dinuba'
|
Label string `json:"label"` // 1234 Main St, Dinuba, CA, USA
|
||||||
LocalityGID string `json:"locality_gid"` // 'whosonfirst:locality:85922491'
|
Layer string `json:"layer"` // 'address'
|
||||||
MatchType string `json:"match_type"` // 'exact'
|
Locality string `json:"locality"` // 'Dinuba'
|
||||||
Name string `json:"name"` // '1234 Main St'
|
LocalityGID string `json:"locality_gid"` // 'whosonfirst:locality:85922491'
|
||||||
PostalCode string `json:"postalcode"` // '93618'
|
MatchType string `json:"match_type"` // 'exact'
|
||||||
Precision string `json:"precision"` // 'centroid'
|
Name string `json:"name"` // '1234 Main St'
|
||||||
Region string `json:"region"` // 'California'
|
PostalCode string `json:"postalcode"` // '93618'
|
||||||
RegionA string `json:"region_a"` // 'CA'
|
Precision string `json:"precision"` // 'centroid'
|
||||||
RegionGID string `json:"region_gid"` // 'whosonfirst:region:85688637'
|
Region string `json:"region"` // 'California'
|
||||||
Source string `json:"source"` // 'openaddresses'
|
RegionA string `json:"region_a"` // 'CA'
|
||||||
SourceID string `json:"source"` // 'us/ca/tulare-addresses-county:fe9dfab3d45c4550'
|
RegionGID string `json:"region_gid"` // 'whosonfirst:region:85688637'
|
||||||
Street string `json:"street"` // 'Main Street'
|
Source string `json:"source"` // 'openaddresses'
|
||||||
|
Sources []GeocodeSource `json:"sources"`
|
||||||
|
SourceID string `json:"source_id"` // 'us/ca/tulare-addresses-county:fe9dfab3d45c4550'
|
||||||
|
Street string `json:"street"` // 'Main Street'
|
||||||
}
|
}
|
||||||
|
|
||||||
// GeocodeSource represents a source of geocoding data
|
// GeocodeSource represents a source of geocoding data
|
||||||
type GeocodeSource struct {
|
type GeocodeSource struct {
|
||||||
|
FixitURL string `json:"fixit_url"`
|
||||||
Source string `json:"source"`
|
Source string `json:"source"`
|
||||||
SourceID string `json:"source_id"`
|
SourceID string `json:"source_id"`
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue