diff --git a/README.md b/README.md index 0aa284a..5b778bf 100644 --- a/README.md +++ b/README.md @@ -14,8 +14,22 @@ The code for the batch jobs that produce the datasets are not included in this r [Getting started guide](docs/getting-started.md) +## Documentation + +### Forti JSON format + +Forti serializes the forecast timeseries in a custom json format, based on [GeoJSON](https://geojson.org/) + +The format is semantically versioned with a [jsonschema](forti.schema.v1.0.0.json) in this repo. + ## Development +### Release new json format version + +``` +go run ./jsonfrontend/cmd/generate-jsonschema -version vX.Y.Z > forti.schema.json +``` + ### Test ## Architecture diff --git a/forti.schema.v1.0.0.json b/forti.schema.v1.0.0.json new file mode 100644 index 0000000..bf87c72 --- /dev/null +++ b/forti.schema.v1.0.0.json @@ -0,0 +1,165 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/metno/forti/forecast.schema.v1.0.0.json", + "$ref": "#/$defs/GeoJSON", + "$defs": { + "Forecast": { + "properties": { + "meta": { + "$ref": "#/$defs/Metadata" + }, + "timeseries": { + "items": { + "$ref": "#/$defs/TimeStep" + }, + "type": "array" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "meta", + "timeseries" + ] + }, + "ForecastDetails": { + "additionalProperties": { + "type": "number" + }, + "type": "object" + }, + "GeoJSON": { + "properties": { + "type": { + "type": "string", + "enum": [ + "Feature" + ] + }, + "geometry": { + "$ref": "#/$defs/Geometry" + }, + "properties": { + "$ref": "#/$defs/Forecast" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "type", + "geometry" + ] + }, + "Geometry": { + "properties": { + "type": { + "type": "string", + "enum": [ + "Point" + ] + }, + "coordinates": { + "items": { + "type": "number" + }, + "type": "array", + "maxItems": 3, + "minItems": 2, + "description": "Longitude and latitude" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "type", + "coordinates" + ] + }, + "Metadata": { + "properties": { + "updated_at": { + "type": "string", + "format": "date-time", + "description": "Time of the last forecast update" + }, + "error": { + "type": "string", + "description": "Error message if the forecast is unavailable" + }, + "units": { + "additionalProperties": { + "type": "string" + }, + "type": "object", + "description": "Maps each forecast parameter name to its physical unit" + }, + "radar_coverage": { + "type": "string", + "description": "Custom metadata used only for timeseries forecasts from radar data. Radar coverage status for the location" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "updated_at", + "units" + ] + }, + "Summary": { + "properties": { + "symbol_code": { + "type": "string", + "description": "Weather symbol identifier" + }, + "symbol_confidence": { + "type": "string", + "description": "Confidence level of the weather symbol" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "symbol_code" + ] + }, + "TimeStep": { + "properties": { + "time": { + "type": "string", + "format": "date-time", + "description": "Start time of the forecast interval" + }, + "data": { + "additionalProperties": { + "$ref": "#/$defs/TimestepData" + }, + "type": "object", + "description": "Forecast data keyed by interval length e.g. instance" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "time", + "data" + ] + }, + "TimestepData": { + "properties": { + "summary": { + "$ref": "#/$defs/Summary", + "description": "Summary of the forecast parameters" + }, + "details": { + "$ref": "#/$defs/ForecastDetails", + "description": "List of forecast parameters and their numerical values" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "details" + ] + } + } +} diff --git a/go.mod b/go.mod index 1d5740a..2baa5fa 100644 --- a/go.mod +++ b/go.mod @@ -40,7 +40,9 @@ require ( github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.2 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.42.3 // indirect github.com/aws/smithy-go v1.26.0 // indirect + github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/beorn7/perks v1.0.1 // indirect + github.com/buger/jsonparser v1.1.2 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect @@ -50,8 +52,10 @@ require ( github.com/google/wire v0.7.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect github.com/googleapis/gax-go/v2 v2.19.0 // indirect + github.com/invopop/jsonschema v0.14.0 // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/pb33f/ordered-map/v2 v2.3.1 // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.67.3 // indirect @@ -63,6 +67,7 @@ require ( go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect go.opentelemetry.io/otel/trace v1.43.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect + go.yaml.in/yaml/v4 v4.0.0-rc.2 // indirect golang.org/x/crypto v0.51.0 // indirect golang.org/x/net v0.55.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect diff --git a/go.sum b/go.sum index 70b2c6a..ef54046 100644 --- a/go.sum +++ b/go.sum @@ -74,8 +74,12 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.42.3 h1:ErklX/7uhSbkAAeyQD/Y1OoQ9hO3 github.com/aws/aws-sdk-go-v2/service/sts v1.42.3/go.mod h1:ULe4HCzfKPiR6R3HEurE3b1upEkuk8AkMrOKtaOxKO8= github.com/aws/smithy-go v1.26.0 h1:9ouqbi+NyKP7fV3Te7UElCwdAb6Y8uk7LGwPE5tVe/s= github.com/aws/smithy-go v1.26.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk= +github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik= @@ -120,6 +124,8 @@ github.com/googleapis/gax-go/v2 v2.19.0 h1:fYQaUOiGwll0cGj7jmHT/0nPlcrZDFPrZRhTs github.com/googleapis/gax-go/v2 v2.19.0/go.mod h1:w2ROXVdfGEVFXzmlciUU4EdjHgWvB5h2n6x/8XSTTJA= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 h1:Ovs26xHkKqVztRpIrF/92BcuyuQ/YW4NSIpoGtfXNho= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= +github.com/invopop/jsonschema v0.14.0 h1:MHQqLhvpNUZfw+hM3AZDYK7jxO8FZoQeQM77g8iyZjg= +github.com/invopop/jsonschema v0.14.0/go.mod h1:ygm6C2EaVNMBDPpaPlnOA2pFAxBnxGjFlMZABxm9n2I= github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= @@ -130,12 +136,14 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/metno/go-weathersymbol v0.1.1 h1:IgYHGkTFL28i0mlBo9b5BkF19+a3/pLxwz+tW7JwyOI= -github.com/metno/go-weathersymbol v0.1.1/go.mod h1:YisSf88s5ZtgpZuKW4LgEB6IanDuCBt3lXfh4JB/diw= github.com/metno/forti-internalformat v0.1.0 h1:uvBfDaB3eaIjNYXCl0qYnDqykodvkVDXk5g2fIssGiI= github.com/metno/forti-internalformat v0.1.0/go.mod h1:91hkl5tZDcNeZKHkw72u6EFGyiZWZWv7ijxX9R4mmtI= +github.com/metno/go-weathersymbol v0.1.1 h1:IgYHGkTFL28i0mlBo9b5BkF19+a3/pLxwz+tW7JwyOI= +github.com/metno/go-weathersymbol v0.1.1/go.mod h1:YisSf88s5ZtgpZuKW4LgEB6IanDuCBt3lXfh4JB/diw= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/pb33f/ordered-map/v2 v2.3.1 h1:5319HDO0aw4DA4gzi+zv4FXU9UlSs3xGZ40wcP1nBjY= +github.com/pb33f/ordered-map/v2 v2.3.1/go.mod h1:qxFQgd0PkVUtOMCkTapqotNgzRhMPL7VvaHKbd1HnmQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= @@ -178,6 +186,8 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +go.yaml.in/yaml/v4 v4.0.0-rc.2 h1:/FrI8D64VSr4HtGIlUtlFMGsm7H7pWTbj6vOLVZcA6s= +go.yaml.in/yaml/v4 v4.0.0-rc.2/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= gocloud.dev v0.46.0 h1:niIuZwSjMtBx8K+ITB2s5kZullB13PGOS2ZoQPZxQ4Q= gocloud.dev v0.46.0/go.mod h1:ACQe+2qO+hEO+pdcvvsM+RB63r8TyGD1W3ESCLFyzvM= golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= diff --git a/jsonfrontend/cmd/generate-jsonschema/main.go b/jsonfrontend/cmd/generate-jsonschema/main.go new file mode 100644 index 0000000..7d222a7 --- /dev/null +++ b/jsonfrontend/cmd/generate-jsonschema/main.go @@ -0,0 +1,31 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" + "log" + + "github.com/invopop/jsonschema" + "github.com/metno/forti/jsonfrontend/pkg/jsonformat" +) + +func main() { + version := flag.String("version", "", "semantic version to embed in the schema $id (e.g. v1.0.0)") + flag.Parse() + + schema := jsonschema.Reflect(&jsonformat.GeoJSON{}) + + if *version != "" { + schema.ID = jsonschema.ID(fmt.Sprintf( + "https://raw.githubusercontent.com/metno/forti/forecast.schema.%s.json", + *version, + )) + } + + data, err := json.MarshalIndent(schema, "", " ") + if err != nil { + log.Fatal(err) + } + fmt.Println(string(data)) +} diff --git a/jsonfrontend/pkg/jsonformat/geojson.go b/jsonfrontend/pkg/jsonformat/geojson.go index d98ef5a..1816579 100644 --- a/jsonfrontend/pkg/jsonformat/geojson.go +++ b/jsonfrontend/pkg/jsonformat/geojson.go @@ -6,15 +6,15 @@ import ( ) type GeoJSON struct { - Type string `json:"type"` + Type string `json:"type" jsonschema:"enum=Feature"` Geometry Geometry `json:"geometry"` Properties *Forecast `json:"properties,omitempty"` } type GeoJSONCoordinate float32 type Geometry struct { - Type string `json:"type"` - Coordinates []GeoJSONCoordinate `json:"coordinates"` + Type string `json:"type" jsonschema:"enum=Point"` + Coordinates []GeoJSONCoordinate `json:"coordinates" jsonschema:"minItems=2,maxItems=3,description=Longitude and latitude, with optional altitude as a third element"` } func (c GeoJSONCoordinate) MarshalJSON() ([]byte, error) { diff --git a/jsonfrontend/pkg/jsonformat/json.go b/jsonfrontend/pkg/jsonformat/json.go index fca5bba..e2e0f5c 100644 --- a/jsonfrontend/pkg/jsonformat/json.go +++ b/jsonfrontend/pkg/jsonformat/json.go @@ -11,25 +11,25 @@ type Forecast struct { } type Metadata struct { - UpdatedAt time.Time `json:"updated_at"` - Error string `json:"error,omitempty"` - Units map[string]string `json:"units"` - RadarCoverage string `json:"radar_coverage,omitempty"` + UpdatedAt time.Time `json:"updated_at" jsonschema:"description=Time of the last forecast update"` + Error string `json:"error,omitempty" jsonschema:"description=Error message if the forecast is unavailable"` + Units map[string]string `json:"units" jsonschema:"description=Maps each forecast parameter name to its physical unit"` + RadarCoverage string `json:"radar_coverage,omitempty" jsonschema:"description=Custom metadata used only for timeseries forecasts from radar data. Radar coverage status for the location"` } type TimeStep struct { - Time time.Time `json:"time"` - Data map[string]TimestepData `json:"data"` + Time time.Time `json:"time" jsonschema:"description=Start time of the forecast interval"` + Data map[string]TimestepData `json:"data" jsonschema:"description=Forecast data keyed by interval length e.g. instance, next_1_hours"` } type TimestepData struct { - Summary *Summary `json:"summary,omitempty"` - Details ForecastDetails `json:"details"` + Summary *Summary `json:"summary,omitempty" jsonschema:"description=Summary of the forecast parameters"` + Details ForecastDetails `json:"details" jsonschema:"description=List of forecast parameters and their numerical values"` } type Summary struct { - SymbolCode string `json:"symbol_code"` - SymbolConfidence string `json:"symbol_confidence,omitempty"` + SymbolCode string `json:"symbol_code" jsonschema:"description=Weather symbol identifier"` + SymbolConfidence string `json:"symbol_confidence,omitempty" jsonschema:"description=Confidence level of the weather symbol"` } type ForecastDetails map[string]SingleDigitFloat