From 13e91f21207cc2243ba3ac91ad33772fb27b172a Mon Sep 17 00:00:00 2001 From: Teppo Kurki Date: Tue, 22 Mar 2022 19:13:52 +0200 Subject: [PATCH 01/63] Course and Resources API --- RESOURCE_PROVIDER_PLUGINS.md | 352 ++++ SERVERPLUGINS.md | 140 +- WORKING_WITH_RESOURCES_API.md | 303 +++ package.json | 7 + .../src/components/Sidebar/Sidebar.js | 4 + packages/server-api/src/index.ts | 55 +- packages/server-api/src/resourcetypes.ts | 87 + settings/n2k-from-file-settings.json | 5 +- src/@types/api-schema-builder.d.ts | 1 + src/api/course/index.ts | 722 +++++++ src/api/course/openApi.json | 674 ++++++ src/api/index.ts | 49 + src/api/resources/index.ts | 477 +++++ src/api/resources/openApi.json | 1803 +++++++++++++++++ src/api/resources/resources.ts | 152 ++ src/api/resources/validate.ts | 76 + src/api/swagger.ts | 39 + src/apidocs/openapi.json | 12 + src/config/config.ts | 28 +- src/index.ts | 6 +- src/interfaces/plugins.ts | 13 +- src/interfaces/rest.js | 3 +- src/security.ts | 7 + src/serverroutes.js | 4 + src/serverstate/store.ts | 52 + src/subscriptionmanager.ts | 3 +- src/types.ts | 4 - src/types/freeport-promise/index.d.ts | 1 + test/course.ts | 572 ++++++ test/deltacache.js | 6 +- test/multiple-values.js | 3 + .../node_modules/testplugin/index.js | 32 + test/resources.ts | 77 + test/servertestutilities.js | 15 +- test/subscriptions.js | 11 +- test/ts-servertestutilities.ts | 95 + tsconfig.json | 5 +- 37 files changed, 5857 insertions(+), 38 deletions(-) create mode 100644 RESOURCE_PROVIDER_PLUGINS.md create mode 100644 WORKING_WITH_RESOURCES_API.md create mode 100644 packages/server-api/src/resourcetypes.ts create mode 100644 src/@types/api-schema-builder.d.ts create mode 100644 src/api/course/index.ts create mode 100644 src/api/course/openApi.json create mode 100644 src/api/index.ts create mode 100644 src/api/resources/index.ts create mode 100644 src/api/resources/openApi.json create mode 100644 src/api/resources/resources.ts create mode 100644 src/api/resources/validate.ts create mode 100644 src/api/swagger.ts create mode 100644 src/apidocs/openapi.json create mode 100644 src/serverstate/store.ts create mode 100644 src/types/freeport-promise/index.d.ts create mode 100644 test/course.ts create mode 100644 test/resources.ts create mode 100644 test/ts-servertestutilities.ts diff --git a/RESOURCE_PROVIDER_PLUGINS.md b/RESOURCE_PROVIDER_PLUGINS.md new file mode 100644 index 000000000..cb21f5a87 --- /dev/null +++ b/RESOURCE_PROVIDER_PLUGINS.md @@ -0,0 +1,352 @@ +# Resource Provider plugins + +_This document should be read in conjunction with [SERVERPLUGINS.md](./SERVERPLUGINS.md) as it contains additional information regarding the development of plugins that facilitate the storage and retrieval of resource data._ + +To see an example of a resource provider plugin see [resources-provider-plugin](https://github.com/SignalK/resources-provider-plugin/) + +--- + +## Overview + +The SignalK specification defines the path `/signalk/v2/api/resources` for accessing resources to aid in navigation and operation of the vessel. + +It also defines the schema for the following __Common__ resource types: +- routes +- waypoints +- notes +- regions +- charts + +each with its own path under the root `resources` path _(e.g. `/signalk/v2/api/resources/routes`)_. + +It should also be noted that the `/signalk/v2/api/resources` path can also host other types of resource data which can be grouped within a __Custom__ path name _(e.g. `/signalk/v2/api/resources/fishingZones`)_. + +The SignalK server does not natively provide the ability to store or retrieve resource data for either __Common__ and __Custom__ resource types. +This functionality needs to be provided by one or more server plugins that handle the data for specific resource types. + +These plugins are called __Resource Providers__. + +The de-coupling of request handling and data storage provides flexibility to persist resource data in different types of storage to meet the needs of your SignalK implementation. + +Requests for both __Common__ and __Custom__ resource types are handled by the SignalK server, the only difference being that the resource data contained in `POST` and `PUT` requests for __Common__ resource types is validated against the OpenApi schema. + +_Note: A plugin can act as a provider for both __Common__ and __Custom__ resource types._ + +--- +## Server Operation: + +The Signal K server handles all requests to `/signalk/v2/api/resources` (and sub-paths), before passing on the request to the registered resource provider plugin. + +The following operations are performed by the server when a request is received: +- Checks for a registered provider for the resource type +- Checks that the required ResourceProvider methods are defined +- Performs access control check +- For __Common__ resource types, checks the validity of the `resource id` and submitted `resource data`. + +Only after successful completion of all these operations is the request passed on to the registered resource provider plugin. + +--- +## Resource Provider plugin: + +For a plugin to be considered a Resource Provider it needs to register with the SignalK server the following: +- Each resource type provided for by the plugin +- The methods used to action requests. It is these methods that perform the writing, retrieval and deletion of resources from storage. + + +### Resource Provider Interface + +--- +The `ResourceProvider` interface is the means by which the plugin informs the SignalK server each of the resource type(s) it services and the endpoints to which requests should be passed. + +The `ResourceProvider` interface is defined as follows in _`@signalk/server-api`_: + +```typescript +interface ResourceProvider { + type: ResourceType + methods: ResourceProviderMethods +} +``` +where: + +- `type`: The resource type provided for by the plugin. These can be either __Common__ or __Custom__ resource types _(e.g. `routes`, `fishingZones`)_ + +- `methods`: An object implementing the `ResourceProviderMethods` interface defining the methods to which resource requests are passed by the SignalK server. _Note: The plugin __MUST__ implement each method, even if that operation is NOT supported by the plugin!_ + +The `ResourceProviderMethods` interface is defined as follows in _`@signalk/server-api`_: + +```typescript +interface ResourceProviderMethods { + pluginId?: string + listResources: (query: { [key: string]: any }) => Promise<{[id: string]: any}> + getResource: (id: string) => Promise + setResource: ( + id: string, + value: { [key: string]: any } + ) => Promise + deleteResource: (id: string) => Promise +} +``` + + +#### Methods: + +--- + +__`listResources(query)`__: This method is called when a request is made for resource entries that match a specific criteria. + +_Note: It is the responsibility of the resource provider plugin to filter the resources returned as per the supplied query parameters._ + +- `query:` Object contining `key | value` pairs repesenting the parameters by which to filter the returned entries. _e.g. {region: 'fishing_zone'}_ + +returns: `Promise<{[id: string]: any}>` + + +_Example resource request:_ +``` +GET /signalk/v2/api/resources/waypoints?bbox=[5.4,25.7,6.9,31.2] +``` +_ResourceProvider method invocation:_ + +```javascript +listResources( + { + bbox: '5.4,25.7,6.9,31.2' + } +); +``` + +--- +__`getResource(id)`__: This method is called when a request is made for a specific resource entry with the supplied id. If there is no resource associated with the id the call should return Promise.reject. + +- `id:` String containing the target resource entry id. _(e.g. 'urn:mrn:signalk:uuid:07894aba-f151-4099-aa4f-5e5773734b99')_ + +returns: `Promise` + +_Example resource request:_ +``` +GET /signalk/v2/api/resources/routes/urn:mrn:signalk:uuid:07894aba-f151-4099-aa4f-5e5773734b99 +``` +_ResourceProvider method invocation:_ + +```javascript +getResource( + 'urn:mrn:signalk:uuid:07894aba-f151-4099-aa4f-5e5773734b99' +); +``` + +--- +__`setResource(id, value)`__: This method is called when a request is made to save / update a resource entry with the supplied id. The supplied data is a complete resource record. + +- `id:` String containing the id of the resource entry created / updated. _e.g. 'urn:mrn:signalk:uuid:07894aba-f151-4099-aa4f-5e5773734b99'_ + +- `value:` Resource data to be stored. + +returns: `Promise` + +_Example PUT resource request:_ +``` +PUT /signalk/v2/api/resources/routes/urn:mrn:signalk:uuid:07894aba-f151-4099-aa4f-5e5773734b99 {resource_data} +``` +_ResourceProvider method invocation:_ + +```javascript +setResource( + 'urn:mrn:signalk:uuid:07894aba-f151-4099-aa4f-5e5773734b99', + { + name: 'test route', + distance': 8000, + feature: { + type: 'Feature', + geometry: { + type: 'LineString', + coordinates: [[138.5, -38.6], [138.7, -38.2], [138.9, -38.0]] + }, + properties:{} + } + } +); +``` + +_Example POST resource request:_ +``` +POST /signalk/v2/api/resources/routes {resource_data} +``` +_ResourceProvider method invocation:_ + +```javascript +setResource( + '', + { + name: 'test route', + distance': 8000, + feature: { + type: 'Feature', + geometry: { + type: 'LineString', + coordinates: [[138.5, -38.6], [138.7, -38.2], [138.9, -38.0]] + }, + properties:{} + } + } +); +``` + +--- +__`deleteResource(id)`__: This method is called when a request is made to remove the specific resource entry with the supplied resource id. + +- `id:` String containing the target resource entry id. _e.g. 'urn:mrn:signalk:uuid:07894aba-f151-4099-aa4f-5e5773734b99'_ + +returns: `Promise` + +_Example resource request:_ +``` +DELETE /signalk/v2/api/resources/routes/urn:mrn:signalk:uuid:07894aba-f151-4099-aa4f-5e5773734b99 +``` +_ResourceProvider method invocation:_ + +```javascript +deleteResource( + 'urn:mrn:signalk:uuid:07894aba-f151-4099-aa4f-5e5773734b99' +); +``` + +### Registering a Resource Provider: +--- + +To register a plugin as a provider for one or more resource types with the SignalK server, it must call the server's `registerResourceProvider` function for each resource type being serviced during plugin startup. + +The function has the following signature: + +```typescript +app.registerResourceProvider(resourceProvider: ResourceProvider) +``` +where: +- `resourceProvider`: is a reference to a `ResourceProvider` object containing the __resource type__ and __methods__ to receive the requests. + +_Note: If a plugin has already registered as a provider for a resource type, the method throws with an `Error`._ + +_Example:_ +```javascript +import { ResourceProvider } from '@signalk/server-api' + +module.exports = function (app) { + + const plugin = { + id: 'mypluginid', + name: 'My Resource Providerplugin' + } + + const routesProvider: ResourceProvider = { + type: 'routes', + methods: { + listResources: (params) => { + fetchRoutes(params) + ... + }, + getResource: (id) => { + getRoute(id) + ... + }, + setResource: (id, value )=> { + saveRoute(id, value) + ... + }, + deleteResource: (id) => { + deleteRoute(id, value) + ... + } + } + } + + const waypointsProvider: ResourceProvider = { + type: 'waypoints', + methods: { + listResources: (params) => { + fetchWaypoints(params) + ... + }, + getResource: (id) => { + getWaypoint(id) + ... + }, + setResource: (id, value )=> { + saveWaypoint(id, value) + ... + }, + deleteResource: (id) => { + deleteWaypoint(id, value) + ... + } + } + } + + plugin.start = function(options) { + ... + try { + app.registerResourceProvider(routesProvider) + app.registerResourceProvider(waypointsProvider) + } + catch (error) { + // handle error + } + } + + return plugin +} +``` + +### Methods + +A Resource Provider plugin must implement methods to service the requests passed from the server. + +All methods must be implemented even if the plugin does not provide for a specific request. + +Each method should return a __Promise__ on success and `throw` on error or if a request is not serviced. + + + +```javascript +// SignalK server plugin +module.exports = function (app) { + + const plugin = { + id: 'mypluginid', + name: 'My Resource Providerplugin', + start: options => { + ... + app.registerResourceProvider({ + type: 'waypoints', + methods: { + listResources: (params) => { + return new Promise( (resolve, reject) => { + ... + if (ok) { + resolve(resource_list) + } else { + reject( new Error('Error fetching resources!')) + } + }) + }, + getResource: (id) => { + return new Promise( (resolve, reject) => { + ... + if (ok) { + resolve(resource_list) + } else { + reject( new Error('Error fetching resource with supplied id!')) + } + }) + }, + setResource: (id, value )=> { + throw( new Error('Not implemented!')) + }, + deleteResource: (id) => { + throw( new Error('Not implemented!')) + } + } + }) + } + + } +} +``` diff --git a/SERVERPLUGINS.md b/SERVERPLUGINS.md index 6d04a0479..cf9081562 100644 --- a/SERVERPLUGINS.md +++ b/SERVERPLUGINS.md @@ -24,7 +24,9 @@ Whan a plugin's configuration is changed the server will first call `stop` to st ## Getting Started with Plugin Development -To get started with SignalK plugin development, you can follow the following guide. +To get started with SignalK plugin development, you can follow this guide. + +_Note: For plugins acting as a provider for one or more of the SignalK resource types listed in the specification (`routes`, `waypoints`, `notes`, `regions` or `charts`) please refer to __[RESOURCE_PROVIDER_PLUGINS.md](./RESOURCE_PROVIDER_PLUGINS.md)__ for additional details._ ### Project setup @@ -702,6 +704,142 @@ app.registerDeltaInputHandler((delta, next) => { }) ``` +### `app.registerResourceProvider(resourceProvider)` + +See [`RESOURCE_PROVIDER_PLUGINS`](./RESOURCE_PROVIDER_PLUGINS.md) for details. + +--- +### `app.resourcesApi.getResource(resource_type, resource_id)` + +Retrieve data for the supplied SignalK resource_type and resource_id. + +_Note: Requires a registered Resource Provider for the supplied `resource_type`._ + + - `resource_type`: Any Signal K _(i.e. `routes`,`waypoints`, `notes`, `regions` & `charts`)_ + or user defined resource types. + + - `resource_id`: The id of the resource to retrieve _(e.g. `urn:mrn:signalk:uuid:ac3a3b2d-07e8-4f25-92bc-98e7c92f7f1a`)_ + +- returns: `Promise<{[key: string]: any}>` + +_Example:_ +```javascript +app.resourcesApi.getResource( + 'routes', + 'urn:mrn:signalk:uuid:ac3a3b2d-07e8-4f25-92bc-98e7c92f7f1a' +).then (data => { + // route data + console.log(data); + ... +}).catch (error) { + // handle error + console.log(error.message); + ... +} +``` + +### `app.resourcesApi.setResource(resource_type, resource_id, resource_data)` + +Create / update value of the resource with the supplied SignalK resource_type and resource_id. + +_Note: Requires a registered Resource Provider for the supplied `resource_type`._ + + - `resource_type`: Any Signal K _(i.e. `routes`,`waypoints`, `notes`, `regions` & `charts`)_ + or user defined resource types. + + - `resource_id`: The id of the resource to retrieve _(e.g. `urn:mrn:signalk:uuid:ac3a3b2d-07e8-4f25-92bc-98e7c92f7f1a`)_ + + - `resource_data`: A complete and valid resource record. + +- returns: `Promise` + +_Example:_ +```javascript +app.resourcesApi.setResource( + 'waypoints', + 'urn:mrn:signalk:uuid:ac3a3b2d-07e8-4f25-92bc-98e7c92f7f1a', + { + "position": {"longitude": 138.5, "latitude": -38.6}, + "feature": { + "type":"Feature", + "geometry": { + "type": "Point", + "coordinates": [138.5, -38.6] + }, + "properties":{} + } + } +).then ( () => { + // success + ... +}).catch (error) { + // handle error + console.log(error.message); + ... +} +``` + +### `app.resourcesApi.deleteResource(resource_type, resource_id)` + +Delete the resource with the supplied SignalK resource_type and resource_id. + +_Note: Requires a registered Resource Provider for the supplied `resource_type`._ + +- `resource_type`: Any Signal K _(i.e. `routes`,`waypoints`, `notes`, `regions` & `charts`)_ +or user defined resource types. + +- `resource_id`: The id of the resource to retrieve _(e.g. `urn:mrn:signalk:uuid:ac3a3b2d-07e8-4f25-92bc-98e7c92f7f1a`)_ + +- returns: `Promise` + +_Example:_ +```javascript +app.resourcesApi.deleteResource( + 'notes', + 'urn:mrn:signalk:uuid:ac3a3b2d-07e8-4f25-92bc-98e7c92f7f1a' +).then ( () => { + // success + ... +}).catch (error) { + // handle error + console.log(error.message); + ... +} +``` + +### `app.resourcesApi.listResources(resource_type, params)` + +Retrieve data for the supplied SignalK resource_type and resource_id. + +_Note: Requires a registered Resource Provider for the supplied `resource_type`._ + + - `resource_type`: Any Signal K _(i.e. `routes`,`waypoints`, `notes`, `regions` & `charts`)_ + or user defined resource types. + + - `params`: Object contining `key | value` pairs repesenting the parameters by which to filter the returned entries. + + __Note: The registered Resource Provider must support the supplied parameters for results to be filtered.__ + +- returns: `Promise<{[key: string]: any}>` + +_Example:_ +```javascript +app.resourcesApi.listResources( + 'waypoints', + {region: 'fishing_zone'} +).then (data => { + // success + console.log(data); + ... +}).catch (error) { + // handle error + console.log(error.message); + ... +} +``` + + +--- ### `app.setPluginStatus(msg)` Set the current status of the plugin. The `msg` should be a short message describing the current status of the plugin and will be displayed in the plugin configuration UI and the Dashboard. diff --git a/WORKING_WITH_RESOURCES_API.md b/WORKING_WITH_RESOURCES_API.md new file mode 100644 index 000000000..15660bd0d --- /dev/null +++ b/WORKING_WITH_RESOURCES_API.md @@ -0,0 +1,303 @@ +# Working with the Resources API + + +## Overview + +The SignalK specification defines a number of resources (routes, waypoints, notes, regions & charts) each with its own path under the root `resources` path _(e.g. `/signalk/v2/api/resources/routes`)_. + +The SignalK server validates requests to these resource paths and passes them to a [Resource Provider plugin](RESOURCE_PROVIDER_PLUGINS.md) for storage and retrieval. + + _You can find plugins in the `App Store` section of the server admin UI._ + +Client applications can then use `HTTP` requests to these paths to store (`POST`, `PUT`), retrieve (`GET`) and remove (`DELETE`) resource entries. + +_Note: the ability to store resource entries is controlled by the server security settings so client applications may need to authenticate for write / delete operations to complete successfully._ + + +### Retrieving Resources +--- + +Resource entries are retrived by submitting an HTTP `GET` request to the relevant path. + +For example to return a list of all available routes +```typescript +HTTP GET 'http://hostname:3000/signalk/v2/api/resources/routes' +``` + or alternatively fetch a specific route. +```typescript +HTTP GET 'http://hostname:3000/signalk/v2/api/resources/routes/urn:mrn:signalk:uuid:94052456-65fa-48ce-a85d-41b78a9d2111' +``` + +A filtered list of entries can be retrieved based on criteria such as: + +- being within a bounded area +- distance from vessel +- total entries returned + +by supplying a query string containing `key | value` pairs in the request. + +_Example 1: Retrieve waypoints within 50km of the vessel. Note: distance in meters value should be provided._ + +```typescript +HTTP GET 'http://hostname:3000/signalk/v2/api/resources/waypoints?distance=50000' +``` + +_Example 2: Retrieve up to 20 waypoints within 90km of the vessel._ + +```typescript +HTTP GET 'http://hostname:3000/signalk/v2/api/resources/waypoints?distance=90000&limit=20' +``` + +_Example 3: Retrieve waypoints within a bounded area. Note: the bounded area is supplied as bottom left & top right corner coordinates in the form swLongitude,swLatitude,neLongitude,neLatitude_. + +```typescript +HTTP GET 'http://hostname:3000/signalk/v2/api/resources/waypoints?bbox=[-135.5,38,-134,38.5]' +``` + + +### Deleting Resources +--- + +Resource entries are deleted by submitting an HTTP `DELETE` request to a path containing the `id` of the resource to delete. + +_Example: Delete from storage the route with id `urn:mrn:signalk:uuid:94052456-65fa-48ce-a85d-41b78a9d2111`._ + +```typescript +HTTP DELETE 'http://hostname:3000/signalk/v2/api/resources/routes/urn:mrn:signalk:uuid:94052456-65fa-48ce-a85d-41b78a9d2111' +``` + + +### Creating / updating Resources +--- + +__Creating a new resource entry:__ + +Resource entries are created by submitting an HTTP `POST` request to a path for the relevant resource type. + +```typescript +HTTP POST 'http://hostname:3000/signalk/v2/api/resources/routes' {resource_data} +``` + +The new resource is created and its `id` (generated by the server) is returned in the response message. + +```JSON +{ + "state": "COMPLETED", + "statusCode": 200, + "id": "urn:mrn:signalk:uuid:94052456-65fa-48ce-a85d-41b78a9d2111" +} +``` + +_Note: Each `POST` will generate a new `id` so if the resource data remains the same duplicate resources will be created._ + +__Updating a resource entry:__ + +Resource entries are updated by submitting an HTTP `PUT` request to a path for the relevant resource type that includes the resource `id`. + +```typescript +HTTP PUT 'http://hostname:3000/signalk/v2/api/resources/waypoints/urn:mrn:signalk:uuid:94052456-65fa-48ce-a85d-41b78a9d2111' +``` + +As the `PUT` request replaces the record with the supplied `id`, the submitted resource data should contain a complete record for the resource type being written. + +Each resource type has a specific set of attributes that are required to be supplied before the resource entry can be created or updated. + +If either the submitted resource data or the resource id are invalid then the operation is aborted._ + +_Note: the submitted resource data is validated against the OpenApi schema definition for the relevant resource type._ + +--- + +### Creating Routes, Waypoints & Regions using `POST` + +The Signal K Resources API provides for `POST` requests to create routes, waypoints and regions to only require the resource coordinates. + +See the following sections for information relating to each of these resoource types. + +--- +#### __Routes:__ + +To create a new route entry the body of the request must contain data in the following format: +```javascript +{ + points: [ + {latitude: -38.567,longitude: 135.9467}, + {latitude: -38.967,longitude: 135.2467}, + {latitude: -39.367,longitude: 134.7467}, + {latitude: -39.567,longitude: 134.4467} + ], + name: 'route name', + description: 'description of the route', + properties: { + ... + } +} +``` +where: +- `points (required)`: is an array of route points (latitude and longitude) +- `name`: is text detailing the name of the route +- `description`: is text describing the route +- `properties`: object containing key | value pairs of attributes associated with the route. + + +_Example: Create new route entry using only required attributes._ + +```typescript +HTTP POST 'http://hostname:3000/signalk/v2/api/resources/routes' { + points: [ + {latitude: -38.567,longitude: 135.9467}, + {latitude: -38.967,longitude: 135.2467}, + {latitude: -39.367,longitude: 134.7467}, + {latitude: -39.567,longitude: 134.4467} + ] +} +``` + + +--- +#### __Waypoints:__ + +To create a new waypoint entry the body of the request must contain data in the following format: +```javascript +{ + position: { + latitude: -38.567, + longitude: 135.9467 + }, + name: 'waypoint name', + description: 'description of the waypoint', + properties: { + ... + } +} +``` +where: +- `position (required)`: the latitude and longitude of the waypoint +- `name`: is text detailing the name of the waypoint +- `description`: is text describing the waypoint +- `properties`: object containing key | value pairs of attributes associated with the waypoint. + + +_Example: Create new waypoint entry using only required attributes._ +```typescript +HTTP POST 'http://hostname:3000/signalk/v2/api/resources/waypoints' { + position: { + latitude: -38.567, + longitude: 135.9467 + } +} +``` + +--- +#### __Regions:__ + +To create a new region entry the body of the request must contain data in the following format(s): + +```javascript +{ + points: [ ... ], + name: 'region name', + description: 'description of the region', + attributes: { + ... + } +} +``` +where: +- `points (required)`: is an array containing coordinates defining a "closed" area in one of the formats listed below. +- `name`: is text detailing the name of the region +- `description`: is text describing the region +- `properties`: object containing key | value pairs of attributes associated with the region. + + +**format 1: Single Bounded Area (SBA)** + +An array of points (latitude and longitude) defining the area. + +```javascript +{ + points: [ + {latitude: -38.567,longitude: 135.9467}, + {latitude: -38.967,longitude: 135.2467}, + {latitude: -39.367,longitude: 134.7467}, + {latitude: -39.567,longitude: 134.4467}, + {latitude: -38.567,longitude: 135.9467} + ] +} +``` + +**format 2: Bounded area containing other areas (Polygon)** + +An array of Single Bounded Areas (`format 1`). The first entry of the array defines an area which contains the areas defined in the remainder of the array entries. + +```javascript +{ + points: [ + [ + {latitude: -38.567,longitude: 135.9467}, + ... + {latitude: -38.567,longitude: 135.9467} + ], + [ + {latitude: -39.167,longitude: 135.567}, + ... + {latitude: -39.167,longitude: 135.567} + ] + ] +} +``` + + +**format 3: Multiple Bounded areas (MultiPolygon)** + +An array of Polygons (`format 2`). + +```javascript +{ + name: 'region name', + description: 'description of the region', + attributes: { + ... + }, + points: [ + [ + [ + {latitude: -38.567,longitude: 135.9467}, + ... + {latitude: -38.567,longitude: 135.9467} + ], + [ + {latitude: -39.167,longitude: 135.567}, + ... + {latitude: -39.167,longitude: 135.567} + ] + ], + [ + [ + {latitude: -40.567,longitude: 135.9467}, + ... + {latitude: -40.567,longitude: 135.9467} + ], + [ + {latitude: -41.167,longitude: 135.567}, + ... + {latitude: -41.167,longitude: 135.567} + ] + ] + ] +} +``` + + +_Example: Create new region entry using only required attributes._ +```typescript +HTTP POST 'http://hostname:3000/signalk/v2/api/resources/regions' { + points: [ + {latitude: -38.567,longitude: 135.9467}, + {latitude: -38.967,longitude: 135.2467}, + {latitude: -39.367,longitude: 134.7467}, + {latitude: -39.567,longitude: 134.4467}, + {latitude: -38.567,longitude: 135.9467} + ] +} +``` diff --git a/package.json b/package.json index 21bfcabe9..2cc394f6e 100644 --- a/package.json +++ b/package.json @@ -78,11 +78,13 @@ "dependencies": { "@signalk/n2k-signalk": "^2.0.0", "@signalk/nmea0183-signalk": "^3.0.0", + "@signalk/resources-provider": "github:SignalK/resources-provider-plugin", "@signalk/server-admin-ui": "1.40.x", "@signalk/server-api": "1.39.x", "@signalk/signalk-schema": "1.5.1", "@signalk/streams": "2.x", "@types/debug": "^4.1.5", + "api-schema-builder": "^2.0.11", "baconjs": "^1.0.1", "bcryptjs": "^2.4.3", "body-parser": "^1.14.1", @@ -125,6 +127,7 @@ "semver": "^7.1.1", "split": "^1.0.0", "stat-mode": "^1.0.0", + "swagger-ui-express": "^4.1.6", "unzipper": "^0.10.10", "uuid": "^8.1.0", "ws": "^7.0.0" @@ -147,9 +150,11 @@ "@types/mocha": "^8.2.0", "@types/node-fetch": "^2.5.3", "@types/pem": "^1.9.6", + "@types/rmfr": "^2.0.1", "@types/semver": "^7.1.0", "@types/serialport": "^8.0.1", "@types/split": "^1.0.0", + "@types/swagger-ui-express": "^4.1.3", "@types/uuid": "^8.3.1", "chai": "^4.0.0", "chai-json-equal": "0.0.1", @@ -159,6 +164,8 @@ "mocha": "^7.0.1", "prettier": "^1.18.2", "rimraf": "^3.0.2", + "rmfr": "^2.0.0", + "ts-node": "^10.5.0", "tslint": "^5.20.0", "tslint-config-prettier": "^1.18.0", "tslint-plugin-prettier": "^2.0.1", diff --git a/packages/server-admin-ui/src/components/Sidebar/Sidebar.js b/packages/server-admin-ui/src/components/Sidebar/Sidebar.js index ba7f323f0..decb5ad76 100644 --- a/packages/server-admin-ui/src/components/Sidebar/Sidebar.js +++ b/packages/server-admin-ui/src/components/Sidebar/Sidebar.js @@ -267,6 +267,10 @@ const mapStateToProps = (state) => { name: 'Data Fiddler', url: '/serverConfiguration/datafiddler', }, + { + name: 'OpenApi', + url: `${window.location.protocol}//${window.location.host}/admin/openapi`, + }, { name: 'Backup/Restore', url: '/serverConfiguration/backuprestore', diff --git a/packages/server-api/src/index.ts b/packages/server-api/src/index.ts index 00ee85cd4..839c37830 100644 --- a/packages/server-api/src/index.ts +++ b/packages/server-api/src/index.ts @@ -1,8 +1,61 @@ import { IRouter } from 'express' import { PropertyValuesCallback } from './propertyvalues' +export interface Position { + latitude: number + longitude: number + altitude?: number +} + export { PropertyValue, PropertyValues, PropertyValuesCallback } from './propertyvalues' +export * from './resourcetypes' + +export type SignalKResourceType = 'routes' | 'waypoints' |'notes' |'regions' |'charts' +export const SIGNALKRESOURCETYPES: SignalKResourceType[] = [ + 'routes', + 'waypoints', + 'notes', + 'regions', + 'charts' +] +export const isSignalKResourceType = (s: string) => SIGNALKRESOURCETYPES.includes(s as SignalKResourceType) + +export type ResourceType = SignalKResourceType | string + +export interface ResourcesApi { + register: (pluginId: string, provider: ResourceProvider) => void; + unRegister: (pluginId: string) => void; + listResources: (resType: SignalKResourceType, params: { [key: string]: any }) => Promise<{[id: string]: any}> + getResource: (resType: SignalKResourceType, resId: string) => Promise + setResource: ( + resType: SignalKResourceType, + resId: string, + data: { [key: string]: any } + ) => Promise + deleteResource: (resType: SignalKResourceType, resId: string) => Promise +} + +export interface ResourceProvider { + type: ResourceType + methods: ResourceProviderMethods +} + +export interface ResourceProviderMethods { + pluginId?: string + listResources: (query: { [key: string]: any }) => Promise<{[id: string]: any}> + getResource: (id: string) => Promise + setResource: ( + id: string, + value: { [key: string]: any } + ) => Promise + deleteResource: (id: string) => Promise +} + +export interface ResourceProviderRegistry { + registerResourceProvider: (provider: ResourceProvider) => void; +} + type Unsubscribe = () => {} export interface PropertyValuesEmitter { emitPropertyValue: (name: string, value: any) => void @@ -16,7 +69,7 @@ export interface PropertyValuesEmitter { * INCOMPLETE, work in progress. */ - export interface PluginServerApp extends PropertyValuesEmitter {} + export interface PluginServerApp extends PropertyValuesEmitter, ResourceProviderRegistry {} /** * This is the API that a [server plugin](https://github.com/SignalK/signalk-server/blob/master/SERVERPLUGINS.md) must implement. diff --git a/packages/server-api/src/resourcetypes.ts b/packages/server-api/src/resourcetypes.ts new file mode 100644 index 000000000..0f0441958 --- /dev/null +++ b/packages/server-api/src/resourcetypes.ts @@ -0,0 +1,87 @@ +import { Position } from '.' + +export interface Route { + name?: string + description?: string + distance?: number + start?: string + end?: string + feature: { + type: 'Feature' + geometry: { + type: 'LineString' + coordinates: GeoJsonLinestring + } + properties?: object + id?: string + } +} + +export interface Waypoint { + name?: string, + description?: string, + feature: { + type: 'Feature' + geometry: { + type: 'Point' + coordinates: GeoJsonPoint + } + properties?: object + id?: string + } +} + +export interface Note { + name?: string + description?: string + href?: string + position?: Position + geohash?: string + mimeType?: string + url?: string +} + +export interface Region { + name?: string + description?: string + feature: Polygon | MultiPolygon +} + +export interface Chart { + name: string + identifier: string + description?: string + tilemapUrl?: string + chartUrl?: string + geohash?: string + region?: string + scale?: number + chartLayers?: string[] + bounds?: [[number, number], [number, number]] + chartFormat: string +} + +type GeoJsonPoint = [number, number, number?] +type GeoJsonLinestring = GeoJsonPoint[] +type GeoJsonPolygon = GeoJsonLinestring[] +type GeoJsonMultiPolygon = GeoJsonPolygon[] + +interface Polygon { + type: 'Feature' + geometry: { + type: 'Polygon' + coordinates: GeoJsonPolygon + } + properties?: object + id?: string +} + +interface MultiPolygon { + type: 'Feature' + geometry: { + type: 'MultiPolygon' + coordinates: GeoJsonMultiPolygon + } + properties?: object + id?: string +} diff --git a/settings/n2k-from-file-settings.json b/settings/n2k-from-file-settings.json index f68ad59bd..a6865040e 100644 --- a/settings/n2k-from-file-settings.json +++ b/settings/n2k-from-file-settings.json @@ -37,8 +37,5 @@ ] } ], - "interfaces": {}, - "security": { - "strategy": "./tokensecurity" - } + "interfaces": {} } \ No newline at end of file diff --git a/src/@types/api-schema-builder.d.ts b/src/@types/api-schema-builder.d.ts new file mode 100644 index 000000000..90dceeb34 --- /dev/null +++ b/src/@types/api-schema-builder.d.ts @@ -0,0 +1 @@ +declare module 'api-schema-builder' \ No newline at end of file diff --git a/src/api/course/index.ts b/src/api/course/index.ts new file mode 100644 index 000000000..d7078657b --- /dev/null +++ b/src/api/course/index.ts @@ -0,0 +1,722 @@ +import { createDebug } from '../../debug' +const debug = createDebug('signalk-server:api:course') + +import { FullSignalK } from '@signalk/signalk-schema' +import { Application, Request, Response } from 'express' +import _ from 'lodash' +import path from 'path' +import { WithConfig } from '../../app' +import { WithSecurityStrategy } from '../../security' + +import { Position, Route } from '@signalk/server-api' +import { isValidCoordinate } from 'geolib' +import { Responses } from '../' +import { Store } from '../../serverstate/store' + +import { buildSchemaSync } from 'api-schema-builder' +import courseOpenApi from './openApi.json' + +const COURSE_API_SCHEMA = buildSchemaSync(courseOpenApi) + +const SIGNALK_API_PATH = `/signalk/v2/api` +const COURSE_API_PATH = `${SIGNALK_API_PATH}/vessels/self/navigation/course` + +interface CourseApplication + extends Application, + WithConfig, + WithSecurityStrategy { + resourcesApi: { + getResource: (resourceType: string, resourceId: string) => any + } + signalk: FullSignalK + handleMessage: (id: string, data: any) => void +} + +interface DestinationBase { + href?: string + arrivalCircle?: number +} +interface Destination extends DestinationBase { + position?: Position + type?: string +} +interface ActiveRoute extends DestinationBase { + pointIndex?: number + reverse?: boolean +} + +interface CourseInfo { + activeRoute: { + href: string | null + startTime: string | null + pointIndex: number + pointTotal: number + reverse: boolean + } + nextPoint: { + href: string | null + type: string | null + position: Position | null + arrivalCircle: number + } + previousPoint: { + href: string | null + type: string | null + position: Position | null + } +} + +export class CourseApi { + private server: CourseApplication + + private courseInfo: CourseInfo = { + activeRoute: { + href: null, + startTime: null, + pointIndex: 0, + pointTotal: 0, + reverse: false + }, + nextPoint: { + href: null, + type: null, + position: null, + arrivalCircle: 0 + }, + previousPoint: { + href: null, + type: null, + position: null + } + } + + private store: Store + + constructor(app: CourseApplication) { + this.server = app + this.store = new Store( + path.join(app.config.configPath, 'serverstate/course') + ) + } + + async start() { + return new Promise(async resolve => { + this.initCourseRoutes() + + try { + const storeData = await this.store.read() + this.courseInfo = this.validateCourseInfo(storeData) + } catch (error) { + console.error('** No persisted course data (using default) **') + } + debug(this.courseInfo) + this.emitCourseInfo(true) + resolve() + }) + } + + private getVesselPosition() { + return _.get((this.server.signalk as any).self, 'navigation.position') + } + + private validateCourseInfo(info: CourseInfo) { + if (info.activeRoute && info.nextPoint && info.previousPoint) { + return info + } else { + debug(`** Error: Loaded course data is invalid!! (using default) **`) + return this.courseInfo + } + } + + private updateAllowed(request: Request): boolean { + return this.server.securityStrategy.shouldAllowPut( + request, + 'vessels.self', + null, + 'navigation.course' + ) + } + + private initCourseRoutes() { + debug(`** Initialise ${COURSE_API_PATH} path handlers **`) + // return current course information + this.server.get( + `${COURSE_API_PATH}`, + async (req: Request, res: Response) => { + debug(`** GET ${COURSE_API_PATH}`) + res.json(this.courseInfo) + } + ) + + this.server.put( + `${COURSE_API_PATH}/restart`, + async (req: Request, res: Response) => { + debug(`** PUT ${COURSE_API_PATH}/restart`) + if (!this.updateAllowed(req)) { + res.status(403).json(Responses.unauthorised) + return + } + if (!this.courseInfo.nextPoint.position) { + res.status(400).json({ + state: 'FAILED', + statusCode: 400, + message: `No active destination!` + }) + return + } + // set previousPoint to vessel position + try { + const position: any = this.getVesselPosition() + if (position && position.value) { + this.courseInfo.previousPoint.position = position.value + this.emitCourseInfo() + res.status(200).json(Responses.ok) + } else { + res.status(400).json({ + state: 'FAILED', + statusCode: 400, + message: `Vessel position unavailable!` + }) + } + } catch (err) { + res.status(400).json({ + state: 'FAILED', + statusCode: 400, + message: `Vessel position unavailable!` + }) + } + } + ) + + this.server.put( + `${COURSE_API_PATH}/arrivalCircle`, + async (req: Request, res: Response) => { + debug(`** PUT ${COURSE_API_PATH}/arrivalCircle`) + if (!this.updateAllowed(req)) { + res.status(403).json(Responses.unauthorised) + return + } + if (this.isValidArrivalCircle(req.body.value)) { + this.courseInfo.nextPoint.arrivalCircle = req.body.value + this.emitCourseInfo() + res.status(200).json(Responses.ok) + } else { + res.status(400).json(Responses.invalid) + } + } + ) + + // set destination + this.server.put( + `${COURSE_API_PATH}/destination`, + async (req: Request, res: Response) => { + debug(`** PUT ${COURSE_API_PATH}/destination`) + if (!this.updateAllowed(req)) { + res.status(403).json(Responses.unauthorised) + return + } + + const endpoint = COURSE_API_SCHEMA[`${COURSE_API_PATH}/destination`].put + if (!endpoint.body.validate(req.body)) { + res.status(400).json(endpoint.body.errors) + return + } + + const result = await this.setDestination(req.body) + if (result) { + this.emitCourseInfo() + res.status(200).json(Responses.ok) + } else { + res.status(400).json(Responses.invalid) + } + } + ) + + // clear destination + this.server.delete( + `${COURSE_API_PATH}/destination`, + async (req: Request, res: Response) => { + debug(`** DELETE ${COURSE_API_PATH}/destination`) + if (!this.updateAllowed(req)) { + res.status(403).json(Responses.unauthorised) + return + } + this.clearDestination() + this.emitCourseInfo() + res.status(200).json(Responses.ok) + } + ) + + // set activeRoute + this.server.put( + `${COURSE_API_PATH}/activeRoute`, + async (req: Request, res: Response) => { + debug(`** PUT ${COURSE_API_PATH}/activeRoute`) + if (!this.updateAllowed(req)) { + res.status(403).json(Responses.unauthorised) + return + } + const result = await this.activateRoute(req.body) + if (result) { + this.emitCourseInfo() + res.status(200).json(Responses.ok) + } else { + res.status(400).json(Responses.invalid) + } + } + ) + + // clear activeRoute /destination + this.server.delete( + `${COURSE_API_PATH}/activeRoute`, + async (req: Request, res: Response) => { + debug(`** DELETE ${COURSE_API_PATH}/activeRoute`) + if (!this.updateAllowed(req)) { + res.status(403).json(Responses.unauthorised) + return + } + this.clearDestination() + this.emitCourseInfo() + res.status(200).json(Responses.ok) + } + ) + + this.server.put( + `${COURSE_API_PATH}/activeRoute/:action`, + async (req: Request, res: Response) => { + debug(`** PUT ${COURSE_API_PATH}/activeRoute/${req.params.action}`) + if (!this.updateAllowed(req)) { + res.status(403).json(Responses.unauthorised) + return + } + // fetch active route data + if (!this.courseInfo.activeRoute.href) { + res.status(400).json(Responses.invalid) + return + } + const rte = await this.getRoute(this.courseInfo.activeRoute.href) + if (!rte) { + res.status(400).json(Responses.invalid) + return + } + + if (req.params.action === 'nextPoint') { + if ( + typeof req.body.value === 'number' && + (req.body.value === 1 || req.body.value === -1) + ) { + this.courseInfo.activeRoute.pointIndex = this.parsePointIndex( + this.courseInfo.activeRoute.pointIndex + req.body.value, + rte + ) + } else { + res.status(400).json(Responses.invalid) + return + } + } + + if (req.params.action === 'pointIndex') { + if (typeof req.body.value === 'number') { + this.courseInfo.activeRoute.pointIndex = this.parsePointIndex( + req.body.value, + rte + ) + } else { + res.status(400).json(Responses.invalid) + return + } + } + // reverse direction from current point + if (req.params.action === 'reverse') { + if (typeof req.body.pointIndex === 'number') { + this.courseInfo.activeRoute.pointIndex = req.body.pointIndex + } else { + this.courseInfo.activeRoute.pointIndex = this.calcReversedIndex() + } + this.courseInfo.activeRoute.reverse = !this.courseInfo.activeRoute + .reverse + } + + if (req.params.action === 'refresh') { + this.courseInfo.activeRoute.pointTotal = + rte.feature.geometry.coordinates.length + let idx = -1 + for (let i = 0; i < rte.feature.geometry.coordinates.length; i++) { + if ( + rte.feature.geometry.coordinates[i][0] === + this.courseInfo.nextPoint.position?.longitude && + rte.feature.geometry.coordinates[i][1] === + this.courseInfo.nextPoint.position?.latitude + ) { + idx = i + } + } + if (idx !== -1) { + this.courseInfo.activeRoute.pointIndex = idx + } + this.emitCourseInfo() + res.status(200).json(Responses.ok) + return + } + + // set new destination + this.courseInfo.nextPoint.position = this.getRoutePoint( + rte, + this.courseInfo.activeRoute.pointIndex, + this.courseInfo.activeRoute.reverse + ) + this.courseInfo.nextPoint.type = `RoutePoint` + this.courseInfo.nextPoint.href = null + + // set previousPoint + if (this.courseInfo.activeRoute.pointIndex === 0) { + try { + const position: any = this.getVesselPosition() + if (position && position.value) { + this.courseInfo.previousPoint.position = position.value + this.courseInfo.previousPoint.type = `VesselPosition` + } else { + res.status(400).json(Responses.invalid) + return false + } + } catch (err) { + console.log(`** Error: unable to retrieve vessel position!`) + res.status(400).json(Responses.invalid) + return false + } + } else { + this.courseInfo.previousPoint.position = this.getRoutePoint( + rte, + this.courseInfo.activeRoute.pointIndex - 1, + this.courseInfo.activeRoute.reverse + ) + this.courseInfo.previousPoint.type = `RoutePoint` + } + this.courseInfo.previousPoint.href = null + this.emitCourseInfo() + res.status(200).json(Responses.ok) + } + ) + } + + private calcReversedIndex(): number { + return ( + this.courseInfo.activeRoute.pointTotal - + 1 - + this.courseInfo.activeRoute.pointIndex + ) + } + + private async activateRoute(route: ActiveRoute): Promise { + let rte: any + + if (route.href) { + rte = await this.getRoute(route.href) + if (!rte) { + console.log(`** Could not retrieve route information for ${route.href}`) + return false + } + if (!Array.isArray(rte.feature?.geometry?.coordinates)) { + debug(`** Invalid route coordinate data! (${route.href})`) + return false + } + } else { + return false + } + + const newCourse: CourseInfo = { ...this.courseInfo } + + // set activeroute + newCourse.activeRoute.href = route.href + + if (this.isValidArrivalCircle(route.arrivalCircle as number)) { + newCourse.nextPoint.arrivalCircle = route.arrivalCircle as number + } + + newCourse.activeRoute.startTime = new Date().toISOString() + + if (typeof route.reverse === 'boolean') { + newCourse.activeRoute.reverse = route.reverse + } + + newCourse.activeRoute.pointIndex = this.parsePointIndex( + route.pointIndex as number, + rte + ) + newCourse.activeRoute.pointTotal = rte.feature.geometry.coordinates.length + + // set nextPoint + newCourse.nextPoint.position = this.getRoutePoint( + rte, + newCourse.activeRoute.pointIndex, + newCourse.activeRoute.reverse + ) + newCourse.nextPoint.type = `RoutePoint` + newCourse.nextPoint.href = null + + // set previousPoint + if (newCourse.activeRoute.pointIndex === 0) { + try { + const position: any = this.getVesselPosition() + if (position && position.value) { + this.courseInfo.previousPoint.position = position.value + this.courseInfo.previousPoint.type = `VesselPosition` + } else { + console.log(`** Error: unable to retrieve vessel position!`) + return false + } + } catch (err) { + return false + } + } else { + newCourse.previousPoint.position = this.getRoutePoint( + rte, + newCourse.activeRoute.pointIndex - 1, + newCourse.activeRoute.reverse + ) + newCourse.previousPoint.type = `RoutePoint` + } + newCourse.previousPoint.href = null + + this.courseInfo = newCourse + return true + } + + private async setDestination(dest: Destination): Promise { + const newCourse: CourseInfo = { ...this.courseInfo } + + // set nextPoint + if (this.isValidArrivalCircle(dest.arrivalCircle)) { + newCourse.nextPoint.arrivalCircle = dest.arrivalCircle as number + } + + newCourse.nextPoint.type = + typeof dest.type !== 'undefined' ? dest.type : null + + if (dest.href) { + const typedHref = this.parseHref(dest.href) + if (typedHref) { + debug(`fetching ${JSON.stringify(typedHref)}`) + // fetch waypoint resource details + try { + const r = await this.server.resourcesApi.getResource( + typedHref.type, + typedHref.id + ) + if (isValidCoordinate(r.feature.geometry.coordinates)) { + newCourse.nextPoint.position = { + latitude: r.feature.geometry.coordinates[1], + longitude: r.feature.geometry.coordinates[0] + } + newCourse.nextPoint.href = dest.href + newCourse.nextPoint.type = 'Waypoint' + } else { + debug(`** Invalid waypoint coordinate data! (${dest.href})`) + return false + } + } catch (err) { + console.log(`** Error retrieving and validating ${dest.href}`) + return false + } + } else { + debug(`** Invalid href! (${dest.href})`) + return false + } + } else if (dest.position) { + newCourse.nextPoint.href = null + newCourse.nextPoint.type = 'Location' + if (isValidCoordinate(dest.position)) { + newCourse.nextPoint.position = dest.position + } else { + debug(`** Error: position is not valid`) + return false + } + } else { + return false + } + + // clear activeRoute values + newCourse.activeRoute.href = null + newCourse.activeRoute.startTime = null + newCourse.activeRoute.pointIndex = 0 + newCourse.activeRoute.pointTotal = 0 + newCourse.activeRoute.reverse = false + + // set previousPoint + try { + const position: any = this.getVesselPosition() + if (position && position.value) { + newCourse.previousPoint.position = position.value + newCourse.previousPoint.type = `VesselPosition` + newCourse.previousPoint.href = null + } else { + debug(`** Error: navigation.position.value is undefined! (${position})`) + return false + } + } catch (err) { + console.log(`** Error: unable to retrieve vessel position!`) + return false + } + + this.courseInfo = newCourse + return true + } + + private clearDestination() { + this.courseInfo.activeRoute.href = null + this.courseInfo.activeRoute.startTime = null + this.courseInfo.activeRoute.pointIndex = 0 + this.courseInfo.activeRoute.pointTotal = 0 + this.courseInfo.activeRoute.reverse = false + this.courseInfo.nextPoint.href = null + this.courseInfo.nextPoint.type = null + this.courseInfo.nextPoint.position = null + this.courseInfo.previousPoint.href = null + this.courseInfo.previousPoint.type = null + this.courseInfo.previousPoint.position = null + } + + private isValidArrivalCircle(value: number | undefined): boolean { + return typeof value === 'number' && value >= 0 + } + + private parsePointIndex(index: number, rte: any): number { + if (typeof index !== 'number' || !rte) { + return 0 + } + if (!rte.feature?.geometry?.coordinates) { + return 0 + } + if (!Array.isArray(rte.feature?.geometry?.coordinates)) { + return 0 + } + if (index < 0) { + return 0 + } + if (index > rte.feature?.geometry?.coordinates.length - 1) { + return rte.feature?.geometry?.coordinates.length - 1 + } + return index + } + + private parseHref(href: string): { type: string; id: string } | undefined { + if (!href) { + return undefined + } + + const ref: string[] = href.split('/').slice(-3) + if (ref.length < 3) { + return undefined + } + if (ref[0] !== 'resources') { + return undefined + } + return { + type: ref[1], + id: ref[2] + } + } + + private getRoutePoint(rte: any, index: number, reverse: boolean) { + const pos = reverse + ? rte.feature.geometry.coordinates[ + rte.feature.geometry.coordinates.length - (index + 1) + ] + : rte.feature.geometry.coordinates[index] + const result: Position = { + latitude: pos[1], + longitude: pos[0] + } + if (pos.length === 3) { + result.altitude = pos[2] + } + return result + } + + private async getRoute(href: string): Promise { + const h = this.parseHref(href) + if (h) { + try { + return await this.server.resourcesApi.getResource(h.type, h.id) + } catch (err) { + debug(`** Unable to fetch resource: ${h.type}, ${h.id}`) + return undefined + } + } else { + debug(`** Unable to parse href: ${href}`) + return undefined + } + } + + private buildDeltaMsg(): any { + + const values: Array<{ path: string; value: any }> = [] + const navPath = 'navigation.course' + + debug(this.courseInfo) + + values.push({ + path: `${navPath}.activeRoute.href`, + value: this.courseInfo.activeRoute.href + }) + values.push({ + path: `${navPath}.activeRoute.startTime`, + value: this.courseInfo.activeRoute.startTime + }) + values.push({ + path: `${navPath}.activeRoute.pointIndex`, + value: this.courseInfo.activeRoute.pointIndex + }) + values.push({ + path: `${navPath}.activeRoute.pointTotal`, + value: this.courseInfo.activeRoute.pointTotal + }) + values.push({ + path: `${navPath}.activeRoute.reverse`, + value: this.courseInfo.activeRoute.reverse + }) + + values.push({ + path: `${navPath}.nextPoint.href`, + value: this.courseInfo.nextPoint.href + }) + values.push({ + path: `${navPath}.nextPoint.position`, + value: this.courseInfo.nextPoint.position + }) + values.push({ + path: `${navPath}.nextPoint.type`, + value: this.courseInfo.nextPoint.type + }) + values.push({ + path: `${navPath}.nextPoint.arrivalCircle`, + value: this.courseInfo.nextPoint.arrivalCircle + }) + + values.push({ + path: `${navPath}.previousPoint.position`, + value: this.courseInfo.previousPoint.position + }) + values.push({ + path: `${navPath}.previousPoint.type`, + value: this.courseInfo.previousPoint.type + }) + + return { + updates: [ + { + values: values + } + ] + } + } + + private emitCourseInfo(noSave = false) { + this.server.handleMessage('courseApi', this.buildDeltaMsg()) + if (!noSave) { + this.store.write(this.courseInfo).catch(error => { + console.log(error) + }) + } + } +} diff --git a/src/api/course/openApi.json b/src/api/course/openApi.json new file mode 100644 index 000000000..dcd003d08 --- /dev/null +++ b/src/api/course/openApi.json @@ -0,0 +1,674 @@ +{ + "openapi": "3.0.0", + "info": { + "version": "2.0.0", + "title": "Signal K Course API", + "termsOfService": "http://signalk.org/terms/", + "license": { + "name": "Apache 2.0", + "url": "http://www.apache.org/licenses/LICENSE-2.0.html" + } + }, + "externalDocs": { + "url": "http://signalk.org/specification/", + "description": "Signal K specification." + }, + "servers": [ + { + "url": "https://localhost:3000/signalk/v2/api/vessels/self/navigation" + } + ], + "tags": [ + { + "name": "course", + "description": "Course operations" + }, + { + "name": "destination", + "description": "Destination operations" + }, + { + "name": "activeRoute", + "description": "Route operations" + }, + { + "name": "calculations", + "description": "Calculated course data" + } + ], + "components": { + "schemas": { + "SignalKHrefRoute": { + "type": "string", + "pattern": "^\/resources\/routes\/urn:mrn:signalk:uuid:[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-4[0-9A-Fa-f]{3}-[89ABab][0-9A-Fa-f]{3}-[0-9A-Fa-f]{12}$", + "description": "Pointer to route resource.", + "example": "/resources/routes/urn:mrn:signalk:uuid:ac3a3b2d-07e8-4f25-92bc-98e7c92f7f1a" + }, + "SignalKPosition": { + "type": "object", + "required": [ + "latitude", + "longitude" + ], + "properties": { + "latitude": { + "type": "number", + "format": "float" + }, + "longitude": { + "type": "number", + "format": "float" + }, + "altitude": { + "type": "number", + "format": "float" + } + }, + "example": { + "latitude": 65.4567, + "longitude": 3.3452 + } + }, + "ArrivalCircle": { + "type": "number", + "minimum": 0, + "description": "Radius of arrival zone in meters", + "example": 500 + }, + "HrefWaypointAttribute": { + "type": "object", + "required": ["href"], + "properties": { + "href": { + "type": "string", + "pattern": "^\/resources\/waypoints\/urn:mrn:signalk:uuid:[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-4[0-9A-Fa-f]{3}-[89ABab][0-9A-Fa-f]{3}-[0-9A-Fa-f]{12}$", + "description": "Reference to a related route resource. A pointer to the resource UUID.", + "example": "/resources/waypoints/urn:mrn:signalk:uuid:ac3a3b2d-07e8-4f25-92bc-98e7c92f7f1a" + } + } + }, + "PositionAttribute": { + "type": "object", + "required": ["position"], + "properties": { + "position": { + "description": "Location coordinates.", + "example": { + "latitude": 65.4567, + "longitude": 3.3452 + }, + "allOf": [ + { + "$ref": "#/components/schemas/SignalKPosition" + } + ] + } + } + }, + "PointTypeAttribute": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Type of point.", + "example": "Point of Interest" + } + } + }, + "ArrivalCircleAttribute": { + "type": "object", + "properties": { + "arrivalCircle": { + "$ref": "#/components/schemas/ArrivalCircle" + } + } + }, + "CourseCalculationsModel": { + "type": "object", + "required": ["calcMethod"], + "description": "Request error response", + "properties": { + "calcMethod": { + "type": "string", + "description": "Calculation method by which values are derived.", + "enum": ["Great Circle", "Rhumbline"], + "default": "Great Circle", + "example": "Rhumbline" + }, + "crossTrackError": { + "type": "number", + "description": "The distance in meters from the vessel's present position to the closest point on a line (track) between previousPoint and nextPoint. A negative number indicates that the vessel is currently to the left of this line (and thus must steer right to compensate), a positive number means the vessel is to the right of the line (steer left to compensate).", + "example": 458.784 + }, + "bearingTrackTrue": { + "type": "number", + "minimum": 0, + "description": "The bearing of a line between previousPoint and nextPoint, relative to true north. (angle in radians)", + "example": 4.58491 + }, + "bearingTrackMagnetic": { + "type": "number", + "minimum": 0, + "description": "The bearing of a line between previousPoint and nextPoint, relative to magnetic north. (angle in radians)", + "example": 4.51234 + }, + "estimatedTimeOfArrival": { + "type": "string", + "description": "The estimated time of arrival at nextPoint position.", + "example": "2019-10-02T18:36:12.123+01:00" + }, + "distance": { + "type": "number", + "minimum": 0, + "description": "The distance in meters between the vessel's present position and the nextPoint.", + "example": 10157 + }, + "bearingTrue": { + "type": "number", + "minimum": 0, + "description": "The bearing of a line between the vessel's current position and nextPoint, relative to true north. (angle in radians)", + "example": 4.58491 + }, + "bearingMagnetic": { + "type": "number", + "minimum": 0, + "description": "The bearing of a line between the vessel's current position and nextPoint, relative to magnetic north. (angle in radians)", + "example": 4.51234 + }, + "velocityMadeGood": { + "type": "number", + "description": "The velocity component of the vessel towards the nextPoint in m/s", + "example": 7.2653 + }, + "timeToGo": { + "type": "number", + "minimum": 0, + "description": "Time in seconds to reach nextPoint's perpendicular with current speed & direction.", + "example": 8491 + } + } + } + }, + "responses": { + "200Ok": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "state": { + "type": "string", + "enum": [ + "COMPLETED" + ] + }, + "statusCode": { + "type": "number", + "enum": [ + 200 + ] + } + }, + "required": [ + "state", + "statusCode" + ] + } + } + } + }, + "ErrorResponse": { + "description": "Failed operation", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Request error response", + "properties": { + "state": { + "type": "string", + "enum": [ + "FAILED" + ] + }, + "statusCode": { + "type": "number", + "enum": [ + 404 + ] + }, + "message": { + "type": "string" + } + }, + "required": [ + "state", + "statusCode", + "message" + ] + } + } + } + }, + "CourseResponse": { + "description": "Course details", + "content": { + "application/json": { + "schema": { + "description": "base model for course response", + "type": "object", + "required": [ + "activeRoute", + "nextPoint", + "previousPoint" + ], + "properties": { + "activeRoute": { + "type": "object", + "required": [ + "href", + "startTime", + "pointIndex", + "pointTotal", + "reverse" + ], + "properties": { + "href": { + "$ref": "#/components/schemas/SignalKHrefRoute" + }, + "startTime": { + "type": "string" + }, + "pointIndex": { + "type": "number", + "minimum": 0, + "description": "0 based index of the point in the route that is the current destination" + }, + "pointTotal": { + "type": "number", + "description": "Total number of points in the route" + }, + "reverse": { + "type": "boolean", + "description": "When true indicates the route points are being navigated in reverse order." + } + } + }, + "nextPoint": { + "oneOf": [ + { + "$ref": "#/components/schemas/HrefWaypointAttribute" + }, + { + "$ref": "#/components/schemas/PositionAttribute" + } + ], + "allOf": [ + { + "$ref": "#/components/schemas/PointTypeAttribute" + }, + { + "$ref": "#/components/schemas/ArrivalCircleAttribute" + } + ] + }, + "previousPoint": { + "allOf": [ + { + "$ref": "#/components/schemas/PositionAttribute" + }, + { + "$ref": "#/components/schemas/PointTypeAttribute" + } + ] + } + } + } + } + } + } + } + }, + "paths": { + "/course": { + "get": { + "tags": [ + "course" + ], + "summary": "Retrieve current course details", + "description": "Returns the current course status", + "responses": { + "200": { + "$ref": "#/components/responses/CourseResponse" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + }, + "/course/restart": { + "put": { + "tags": [ + "course" + ], + "summary": "Restart course calculations", + "description": "Sets previousPoint value to current vessel position and bases calculations on update.", + "responses": { + "200": { + "$ref": "#/components/responses/200Ok" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + }, + "/course/arrivalCircle": { + "put": { + "tags": [ + "course" + ], + "summary": "Set arrival zone size", + "description": "Sets the radius of a circle in meters centered at the current destination.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["value"], + "properties": { + "value": { + "$ref": "#/components/schemas/ArrivalCircle" + } + } + } + } + } + }, + "responses": { + "200": { + "$ref": "#/components/responses/200Ok" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + }, + "/course/destination": { + "put": { + "tags": [ + "destination" + ], + "summary": "Set destination", + "description": "Sets nextPoint path with supplied details", + "requestBody": { + "description": "destination details", + "required": true, + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/HrefWaypointAttribute" + }, + { + "$ref": "#/components/schemas/PositionAttribute" + } + ], + "allOf": [ + { + "$ref": "#/components/schemas/ArrivalCircleAttribute" + } + ] + } + } + } + }, + "responses": { + "200": { + "$ref": "#/components/responses/200Ok" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + }, + "delete": { + "tags": [ + "destination" + ], + "summary": "Clear destination", + "description": "Clears all course information", + "responses": { + "200": { + "$ref": "#/components/responses/200Ok" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + }, + "/course/activeRoute": { + "put": { + "tags": [ + "activeRoute" + ], + "summary": "Set active route", + "description": "Sets activeRoute path and sets nextPoint to first point in the route", + "requestBody": { + "description": "Route to activate", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "href" + ], + "properties": { + "href": { + "$ref": "#/components/schemas/SignalKHrefRoute" + }, + "pointIndex": { + "type": "number", + "default": 0, + "minimum": 0, + "description": "0 based index of the point in the route to set as the destination" + }, + "reverse": { + "type": "boolean", + "default": false, + "description": "Set to true to navigate the route points in reverse order." + }, + "arrivalCircle": { + "$ref": "#/components/schemas/ArrivalCircle" + } + } + } + } + } + }, + "responses": { + "200": { + "$ref": "#/components/responses/200Ok" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + }, + "delete": { + "tags": [ + "activeRoute" + ], + "summary": "Clear active route", + "description": "Clears all course information", + "responses": { + "200": { + "$ref": "#/components/responses/200Ok" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + }, + "/course/activeRoute/nextPoint": { + "put": { + "tags": [ + "activeRoute" + ], + "summary": "Set next point in route", + "description": "Sets nextPoint / previousPoint", + "requestBody": { + "description": "destination details", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["value"], + "properties": { + "value": { + "type": "number", + "description": "Index of point in route (-1= previous point)", + "enum": [ + 1, + -1 + ], + "default": 1 + } + } + } + } + } + }, + "responses": { + "200": { + "$ref": "#/components/responses/200Ok" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + }, + "/course/activeRoute/pointIndex": { + "put": { + "tags": [ + "activeRoute" + ], + "summary": "Set point in route as destination.", + "description": "Sets destination to the point with the provided index.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["value"], + "properties": { + "value": { + "type": "number", + "minimum": 0, + "description": "Index of point in route to set as destination.", + "example": 2 + } + } + } + } + } + }, + "responses": { + "200": { + "$ref": "#/components/responses/200Ok" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + }, + "/course/activeRoute/reverse": { + "put": { + "tags": [ + "activeRoute" + ], + "summary": "Reverse route direction.", + "description": "Reverse the direction the active route is navigated.", + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "pointIndex": { + "type": "number", + "minimum": 0, + "description": "Index of point in route to set as destination.", + "example": 2 + } + } + } + } + } + }, + "responses": { + "200": { + "$ref": "#/components/responses/200Ok" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + }, + "/course/activeRoute/refresh": { + "put": { + "tags": [ + "activeRoute" + ], + "summary": "Refresh course information", + "description": "Refresh course values after a change has been made.", + "responses": { + "200": { + "$ref": "#/components/responses/200Ok" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + }, + "/course/calculations": { + "get": { + "tags": [ + "calculations" + ], + "summary": "Course calculations", + "description": "Returns the current course status", + "responses": { + "200": { + "description": "Course data", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CourseCalculationsModel" + } + } + } + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + } + } + } diff --git a/src/api/index.ts b/src/api/index.ts new file mode 100644 index 000000000..d21931863 --- /dev/null +++ b/src/api/index.ts @@ -0,0 +1,49 @@ +import { CourseApi } from './course' +import { ResourcesApi } from './resources' + +export interface ApiResponse { + state: 'FAILED' | 'COMPLETED' | 'PENDING' + statusCode: number + message: string + requestId?: string + href?: string + token?: string +} + +export const Responses = { + ok: { + state: 'COMPLETED', + statusCode: 200, + message: 'OK' + }, + invalid: { + state: 'FAILED', + statusCode: 400, + message: `Invalid Data supplied.` + }, + unauthorised: { + state: 'FAILED', + statusCode: 403, + message: 'Unauthorised' + }, + notFound: { + state: 'FAILED', + statusCode: 404, + message: 'Resource not found.' + } +} + +const APIS = { + resourcesApi: ResourcesApi, + courseApi: CourseApi +} + +export const startApis = (app: any) => + Promise.all( + Object.entries(APIS).map((value: any) => { + const [apiName, apiConstructor] = value + const api = new apiConstructor(app) + app[apiName] = api + return api.start() + }) + ) diff --git a/src/api/resources/index.ts b/src/api/resources/index.ts new file mode 100644 index 000000000..df74fc03a --- /dev/null +++ b/src/api/resources/index.ts @@ -0,0 +1,477 @@ +import { createDebug } from '../../debug' +const debug = createDebug('signalk-server:api:resources') + +import { + isSignalKResourceType, + ResourceProvider, + ResourceProviderMethods, + SignalKResourceType +} from '@signalk/server-api' + +import { Application, NextFunction, Request, Response } from 'express' +import { v4 as uuidv4 } from 'uuid' +// import { SignalKMessageHub } from '../../app' +import { WithSecurityStrategy } from '../../security' + +import { Responses } from '../' +import { fromPostData } from './resources' +import { validate } from './validate' + +export const RESOURCES_API_PATH = `/signalk/v2/api/resources` + +const UUID_PREFIX = 'urn:mrn:signalk:uuid:' +export const skUuid = () => `${UUID_PREFIX}${uuidv4()}` + +interface ResourceApplication extends Application, WithSecurityStrategy { + handleMessage: (id: string, data: any) => void +} + +export class ResourcesApi { + private resProvider: { [key: string]: ResourceProviderMethods | null } = {} + + constructor(app: ResourceApplication) { + this.initResourceRoutes(app) + } + + async start() { + return Promise.resolve() + } + + register(pluginId: string, provider: ResourceProvider) { + debug(`** Registering provider(s)....${pluginId} ${provider?.type}`) + if (!provider) { + throw new Error(`Error registering provider ${pluginId}!`) + } + if (!provider.type) { + throw new Error(`Invalid ResourceProvider.type value!`) + } + if (!this.resProvider[provider.type]) { + if ( + !provider.methods.listResources || + !provider.methods.getResource || + !provider.methods.setResource || + !provider.methods.deleteResource || + typeof provider.methods.listResources !== 'function' || + typeof provider.methods.getResource !== 'function' || + typeof provider.methods.setResource !== 'function' || + typeof provider.methods.deleteResource !== 'function' + ) { + throw new Error(`Error missing ResourceProvider.methods!`) + } else { + provider.methods.pluginId = pluginId + this.resProvider[provider.type] = provider.methods + } + debug(this.resProvider[provider.type]) + } else { + const msg = `Error: ${provider?.type} alreaady registered!` + debug(msg) + throw new Error(msg) + } + } + + unRegister(pluginId: string) { + if (!pluginId) { + return + } + debug(`** Un-registering ${pluginId} resource provider(s)....`) + for (const resourceType in this.resProvider) { + if (this.resProvider[resourceType]?.pluginId === pluginId) { + debug(`** Un-registering ${resourceType}....`) + delete this.resProvider[resourceType] + } + } + debug(JSON.stringify(this.resProvider)) + } + + getResource(resType: SignalKResourceType, resId: string) { + debug(`** getResource(${resType}, ${resId})`) + if (!this.checkForProvider(resType)) { + return Promise.reject(new Error(`No provider for ${resType}`)) + } + return this.resProvider[resType]?.getResource(resId) + } + + listResources(resType: SignalKResourceType, params: { [key: string]: any }) { + debug(`** listResources(${resType}, ${JSON.stringify(params)})`) + if (!this.checkForProvider(resType)) { + return Promise.reject(new Error(`No provider for ${resType}`)) + } + return this.resProvider[resType]?.listResources(params) + } + + setResource( + resType: SignalKResourceType, + resId: string, + data: { [key: string]: any } + ) { + debug(`** setResource(${resType}, ${resId}, ${JSON.stringify(data)})`) + if (!this.checkForProvider(resType)) { + return Promise.reject(new Error(`No provider for ${resType}`)) + } + if (isSignalKResourceType(resType)) { + let isValidId: boolean + if (resType === 'charts') { + isValidId = validate.chartId(resId) + } else { + isValidId = validate.uuid(resId) + } + if (!isValidId) { + return Promise.reject( + new Error(`Invalid resource id provided (${resId})`) + ) + } + validate.resource(resType as SignalKResourceType, resId, 'PUT', data) + } + + return this.resProvider[resType]?.setResource(resId, data) + } + + deleteResource(resType: SignalKResourceType, resId: string) { + debug(`** deleteResource(${resType}, ${resId})`) + if (!this.checkForProvider(resType)) { + return Promise.reject(new Error(`No provider for ${resType}`)) + } + + return this.resProvider[resType]?.deleteResource(resId) + } + + private initResourceRoutes(server: ResourceApplication) { + const updateAllowed = (req: Request): boolean => { + return server.securityStrategy.shouldAllowPut( + req, + 'vessels.self', + null, + 'resources' + ) + } + + // list all serviced paths under resources + server.get(`${RESOURCES_API_PATH}`, (req: Request, res: Response) => { + res.json(this.getResourcePaths()) + }) + + // facilitate retrieval of a specific resource + server.get( + `${RESOURCES_API_PATH}/:resourceType/:resourceId`, + async (req: Request, res: Response, next: NextFunction) => { + debug(`** GET ${RESOURCES_API_PATH}/:resourceType/:resourceId`) + if ( + !this.checkForProvider(req.params.resourceType as SignalKResourceType) + ) { + debug('** No provider found... calling next()...') + next() + return + } + try { + const retVal = await this.resProvider[ + req.params.resourceType + ]?.getResource(req.params.resourceId) + res.json(retVal) + } catch (err) { + res.status(404).json({ + state: 'FAILED', + statusCode: 404, + message: `Resource not found! (${req.params.resourceId})` + }) + } + } + ) + + // facilitate retrieval of a collection of resource entries + server.get( + `${RESOURCES_API_PATH}/:resourceType`, + async (req: Request, res: Response, next: NextFunction) => { + debug(`** GET ${RESOURCES_API_PATH}/:resourceType`) + if ( + !this.checkForProvider(req.params.resourceType as SignalKResourceType) + ) { + debug('** No provider found... calling next()...') + next() + return + } + + const parsedQuery = Object.entries(req.query).reduce( + (acc: any, [name, value]) => { + try { + acc[name] = JSON.parse(value as string) + return acc + } catch (error) { + acc[name] = value + return acc + } + }, + {} + ) + + if (isSignalKResourceType(req.params.resourceType)) { + try { + validate.query( + req.params.resourceType as SignalKResourceType, + undefined, + req.method, + parsedQuery + ) + } catch (e) { + res.status(400).json({ + state: 'FAILED', + statusCode: 400, + message: e.message + }) + return + } + } + + try { + const retVal = await this.resProvider[ + req.params.resourceType + ]?.listResources(parsedQuery) + res.json(retVal) + } catch (err) { + console.error(err) + res.status(404).json({ + state: 'FAILED', + statusCode: 404, + message: `Error retrieving resources!` + }) + } + } + ) + + // facilitate creation of new resource entry of supplied type + server.post( + `${RESOURCES_API_PATH}/:resourceType/`, + async (req: Request, res: Response, next: NextFunction) => { + debug(`** POST ${RESOURCES_API_PATH}/${req.params.resourceType}`) + + if ( + !this.checkForProvider(req.params.resourceType as SignalKResourceType) + ) { + debug('** No provider found... calling next()...') + next() + return + } + + if (!updateAllowed(req)) { + res.status(403).json(Responses.unauthorised) + return + } + if (isSignalKResourceType(req.params.resourceType)) { + try { + validate.resource( + req.params.resourceType as SignalKResourceType, + undefined, + req.method, + req.body + ) + } catch (e) { + res.status(400).json({ + state: 'FAILED', + statusCode: 400, + message: e.message + }) + return + } + } + + let id: string + if (req.params.resourceType === 'charts') { + id = req.body.identifier + } else { + id = skUuid() + } + + try { + await this.resProvider[req.params.resourceType]?.setResource( + id, + fromPostData(req.params.resourceType, req.body) + ) + + server.handleMessage( + this.resProvider[req.params.resourceType]?.pluginId as string, + this.buildDeltaMsg( + req.params.resourceType as SignalKResourceType, + id, + req.body + ) + ) + res.status(201).json({ + state: 'COMPLETED', + statusCode: 201, + id + }) + } catch (err) { + console.log(err) + res.status(404).json({ + state: 'FAILED', + statusCode: 404, + message: `Error saving ${req.params.resourceType} resource (${id})!` + }) + } + } + ) + + // facilitate creation / update of resource entry at supplied id + server.put( + `${RESOURCES_API_PATH}/:resourceType/:resourceId`, + async (req: Request, res: Response, next: NextFunction) => { + debug(`** PUT ${RESOURCES_API_PATH}/:resourceType/:resourceId`) + if ( + !this.checkForProvider(req.params.resourceType as SignalKResourceType) + ) { + debug('** No provider found... calling next()...') + next() + return + } + + if (!updateAllowed(req)) { + res.status(403).json(Responses.unauthorised) + return + } + if (isSignalKResourceType(req.params.resourceType)) { + let isValidId: boolean + if (req.params.resourceType === 'charts') { + isValidId = validate.chartId(req.params.resourceId) + } else { + isValidId = validate.uuid(req.params.resourceId) + } + if (!isValidId) { + res.status(400).json({ + state: 'FAILED', + statusCode: 400, + message: `Invalid resource id provided (${req.params.resourceId})` + }) + return + } + + debug(req.body) + try { + validate.resource( + req.params.resourceType as SignalKResourceType, + req.params.resourceId, + req.method, + req.body + ) + } catch (e) { + res.status(400).json({ + state: 'FAILED', + statusCode: 400, + message: e.message + }) + return + } + } + + try { + const retVal = await this.resProvider[ + req.params.resourceType + ]?.setResource(req.params.resourceId, req.body) + + server.handleMessage( + this.resProvider[req.params.resourceType]?.pluginId as string, + this.buildDeltaMsg( + req.params.resourceType as SignalKResourceType, + req.params.resourceId, + req.body + ) + ) + res.status(200).json({ + state: 'COMPLETED', + statusCode: 200, + message: req.params.resourceId + }) + } catch (err) { + res.status(404).json({ + state: 'FAILED', + statusCode: 404, + message: `Error saving ${req.params.resourceType} resource (${req.params.resourceId})!` + }) + } + } + ) + + // facilitate deletion of specific of resource entry at supplied id + server.delete( + `${RESOURCES_API_PATH}/:resourceType/:resourceId`, + async (req: Request, res: Response, next: NextFunction) => { + debug(`** DELETE ${RESOURCES_API_PATH}/:resourceType/:resourceId`) + if ( + !this.checkForProvider(req.params.resourceType as SignalKResourceType) + ) { + debug('** No provider found... calling next()...') + next() + return + } + + if (!updateAllowed(req)) { + res.status(403).json(Responses.unauthorised) + return + } + try { + const retVal = await this.resProvider[ + req.params.resourceType + ]?.deleteResource(req.params.resourceId) + + server.handleMessage( + this.resProvider[req.params.resourceType]?.pluginId as string, + this.buildDeltaMsg( + req.params.resourceType as SignalKResourceType, + req.params.resourceId, + null + ) + ) + res.status(200).json({ + state: 'COMPLETED', + statusCode: 200, + message: req.params.resourceId + }) + } catch (err) { + res.status(400).json({ + state: 'FAILED', + statusCode: 400, + message: `Error deleting resource (${req.params.resourceId})!` + }) + } + } + ) + } + + private getResourcePaths(): { [key: string]: any } { + const resPaths: { [key: string]: any } = {} + for (const i in this.resProvider) { + if (this.resProvider.hasOwnProperty(i)) { + resPaths[i] = { + description: `Path containing ${ + i.slice(-1) === 's' ? i.slice(0, i.length - 1) : i + } resources`, + $source: this.resProvider[i]?.pluginId + } + } + } + return resPaths + } + + private checkForProvider(resType: SignalKResourceType): boolean { + debug(`** checkForProvider(${resType})`) + debug(this.resProvider[resType]) + return this.resProvider[resType] ? true : false + } + + private buildDeltaMsg( + resType: SignalKResourceType, + resid: string, + resValue: any + ): any { + return { + updates: [ + { + values: [ + { + path: `resources.${resType}.${resid}`, + value: resValue + } + ] + } + ] + } + } +} diff --git a/src/api/resources/openApi.json b/src/api/resources/openApi.json new file mode 100644 index 000000000..5dce2fb98 --- /dev/null +++ b/src/api/resources/openApi.json @@ -0,0 +1,1803 @@ +{ + "openapi": "3.0.0", + "info": { + "version": "2.0.0", + "title": "Signal K Resources API", + "termsOfService": "http://signalk.org/terms/", + "license": { + "name": "Apache 2.0", + "url": "http://www.apache.org/licenses/LICENSE-2.0.html" + } + }, + "externalDocs": { + "url": "http://signalk.org/specification/", + "description": "Signal K specification." + }, + "servers": [ + { + "description": "Signal K Server", + "url": "http://localhost:3000/signalk/v2/api" + } + ], + "tags": [ + { + "name": "resources", + "description": "Signal K resources" + }, + { + "name": "routes", + "description": "Route operations" + }, + { + "name": "waypoints", + "description": "Waypoint operations" + }, + { + "name": "regions", + "description": "Region operations" + }, + { + "name": "notes", + "description": "Note operations" + }, + { + "name": "charts", + "description": "Chart operations" + } + ], + "components": { + "schemas": { + "Coordinate": { + "type": "array", + "maxItems": 3, + "minItems": 2, + "items": { + "type": "number" + } + }, + "LineStringCoordinates": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Coordinate" + } + }, + "PolygonCoordinates": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LineStringCoordinates" + } + }, + "MultiPolygonCoordinates": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PolygonCoordinates" + } + }, + "Point": { + "type": "object", + "description": "GeoJSon Point geometry", + "externalDocs": { + "url": "http://geojson.org/geojson-spec.html#id2" + }, + "required": ["type"], + "properties": { + "type": { + "type": "string", + "enum": [ + "Point" + ] + }, + "coordinates": { + "$ref": "#/components/schemas/Coordinate" + } + } + }, + "LineString": { + "type": "object", + "description": "GeoJSon LineString geometry", + "externalDocs": { + "url": "http://geojson.org/geojson-spec.html#id3" + }, + "required": ["type"], + "properties": { + "type": { + "type": "string", + "enum": [ + "LineString" + ] + }, + "coordinates": { + "$ref": "#/components/schemas/LineStringCoordinates" + } + } + }, + "Polygon": { + "type": "object", + "description": "GeoJSon Polygon geometry", + "externalDocs": { + "url": "http://geojson.org/geojson-spec.html#id4" + }, + "required": ["type"], + "properties": { + "type": { + "type": "string", + "enum": [ + "Polygon" + ] + }, + "coordinates": { + "$ref": "#/components/schemas/PolygonCoordinates" + } + } + }, + "MultiPolygon": { + "type": "object", + "description": "GeoJSon MultiPolygon geometry", + "externalDocs": { + "url": "http://geojson.org/geojson-spec.html#id6" + }, + "required": ["type"], + "properties": { + "type": { + "type": "string", + "enum": [ + "MultiPolygon" + ] + }, + "coordinates": { + "$ref": "#/components/schemas/MultiPolygonCoordinates" + } + } + }, + "SignalKUuid": { + "type": "string", + "pattern": "urn:mrn:signalk:uuid:[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-4[0-9A-Fa-f]{3}-[89ABab][0-9A-Fa-f]{3}-[0-9A-Fa-f]{12}$", + "example": "urn:mrn:signalk:uuid:ac3a3b2d-07e8-4f25-92bc-98e7c92f7f1a" + }, + "SignalKHref": { + "type": "string", + "pattern": "^\/resources\/(\\w*)\/urn:mrn:signalk:uuid:[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-4[0-9A-Fa-f]{3}-[89ABab][0-9A-Fa-f]{3}-[0-9A-Fa-f]{12}$" + }, + "SignalKPosition": { + "type": "object", + "required": [ + "latitude", + "longitude" + ], + "properties": { + "latitude": { + "type": "number", + "format": "float" + }, + "longitude": { + "type": "number", + "format": "float" + }, + "altitude": { + "type": "number", + "format": "float" + } + } + }, + "SignalKPositionArray": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SignalKPosition" + }, + "description": "Array of points.", + "example": [ + { + "latitude": 65.4567, + "longitude": 3.3452 + }, + { + "latitude": 65.5567, + "longitude": 3.3352 + }, + { + "latitude": 65.5777, + "longitude": 3.3261 + } + ] + }, + "SignalKPositionPolygon": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SignalKPositionArray" + }, + "description": "Array of SignalKPositionArray.", + "example": [ + [ + { + "latitude": 65.4567, + "longitude": 3.3452 + }, + { + "latitude": 65.5567, + "longitude": 3.3352 + }, + { + "latitude": 65.5777, + "longitude": 3.3261 + } + ], + [ + { + "latitude": 64.4567, + "longitude": 4.3452 + }, + { + "latitude": 64.5567, + "longitude": 4.3352 + }, + { + "latitude": 64.5777, + "longitude": 4.3261 + } + ] + ] + }, + "SignalKPositionMultiPolygon": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SignalKPositionPolygon" + }, + "description": "Array of SignalKPositionPolygon.", + "example": [ + [ + [ + { + "latitude": 65.4567, + "longitude": 3.3452 + }, + { + "latitude": 65.5567, + "longitude": 3.3352 + }, + { + "latitude": 65.5777, + "longitude": 3.3261 + } + ], + [ + { + "latitude": 64.4567, + "longitude": 4.3452 + }, + { + "latitude": 64.5567, + "longitude": 4.3352 + }, + { + "latitude": 64.5777, + "longitude": 4.3261 + } + ] + ], + [ + [ + { + "latitude": 75.4567, + "longitude": 3.3452 + }, + { + "latitude": 75.5567, + "longitude": 3.3352 + }, + { + "latitude": 75.5777, + "longitude": 3.3261 + } + ], + [ + { + "latitude": 74.4567, + "longitude": 4.3452 + }, + { + "latitude": 74.5567, + "longitude": 4.3352 + }, + { + "latitude": 74.5777, + "longitude": 4.3261 + } + ] + ] + ] + }, + "HrefAttribute": { + "type": "object", + "required": ["href"], + "properties": { + "href": { + "description": "Reference to a related resource. A pointer to the resource UUID.", + "example": "/resources/waypoints/urn:mrn:signalk:uuid:ac3a3b2d-07e8-4f25-92bc-98e7c92f7f1a", + "allOf": [ + { + "$ref": "#/components/schemas/SignalKHref" + } + ] + } + } + }, + "PositionAttribute": { + "type": "object", + "required": ["position"], + "properties": { + "position": { + "description": "Resource location.", + "example": { + "latitude": 65.4567, + "longitude": 3.3452 + }, + "allOf": [ + { + "$ref": "#/components/schemas/SignalKPosition" + } + ] + } + } + }, + "Route": { + "type": "object", + "description": "Signal K Route resource", + "required": [ + "feature" + ], + "properties": { + "name": { + "type": "string", + "description": "Route's common name" + }, + "description": { + "type": "string", + "description": "A description of the route" + }, + "distance": { + "description": "Total distance from start to end", + "type": "number" + }, + "feature": { + "type": "object", + "title": "Feature", + "description": "A GeoJSON feature object which describes a route", + "properties": { + "geometry": { + "$ref": "#/components/schemas/LineString" + }, + "properties": { + "description": "Additional feature properties", + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "RoutePostModel": { + "description": "Route API resource request payload", + "type": "object", + "required": [ + "points" + ], + "properties": { + "name": { + "type": "string", + "description": "Title of route" + }, + "description": { + "type": "string", + "description": "Text describing route" + }, + "points": { + "$ref": "#/components/schemas/SignalKPositionArray" + }, + "properties": { + "type": "object", + "additionalProperties": true + } + } + }, + "Waypoint": { + "description": "Signal K Waypoint resource", + "type": "object", + "required": [ + "feature" + ], + "properties": { + "name": { + "type": "string", + "description": "Waypoint's common name" + }, + "description": { + "type": "string", + "description": "A description of the waypoint" + }, + "feature": { + "type": "object", + "title": "Feature", + "description": "A Geo JSON feature object which describes a waypoint", + "properties": { + "geometry": { + "$ref": "#/components/schemas/Point" + }, + "properties": { + "description": "Additional feature properties", + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "WaypointPostModel": { + "description": "Waypoint API resource request payload", + "type": "object", + "required": [ + "position" + ], + "properties": { + "name": { + "type": "string", + "description": "Name of waypoint" + }, + "description": { + "type": "string", + "description": "Waypoint description" + }, + "position": { + "allOf": [ + { + "$ref": "#/components/schemas/SignalKPosition" + } + ], + "description": "Waypoint position." + }, + "properties": { + "type": "object", + "additionalProperties": true + } + } + }, + "Region": { + "description": "Signal K Region resource", + "type": "object", + "required": [ + "feature" + ], + "properties": { + "name": { + "type": "string", + "description": "Region's common name" + }, + "description": { + "type": "string", + "description": "A description of the region" + }, + "feature": { + "type": "object", + "title": "Feature", + "description": "A Geo JSON feature object which describes the regions boundary", + "properties": { + "geometry": { + "oneOf": [ + { + "$ref": "#/components/schemas/Polygon" + }, + { + "$ref": "#/components/schemas/MultiPolygon" + } + ] + }, + "properties": { + "description": "Additional feature properties", + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "RegionPostModel": { + "description": "Region API resource request payload", + "type": "object", + "required": [ + "points" + ], + "properties": { + "name": { + "type": "string", + "description": "Title of region" + }, + "description": { + "type": "string", + "description": "Text describing region" + }, + "points": { + "oneOf": [ + { + "$ref": "#/components/schemas/SignalKPositionArray" + }, + { + "$ref": "#/components/schemas/SignalKPositionPolygon" + }, + { + "$ref": "#/components/schemas/SignalKPositionMultiPolygon" + } + ] + }, + "properties": { + "type": "object", + "additionalProperties": true + } + } + }, + "NoteBaseModel": { + "description": "Signal K Note resource", + "type": "object", + "properties": { + "title": { + "type": "string", + "description": "Title of note" + }, + "description": { + "type": "string", + "description": "Text describing note" + }, + "mimeType": { + "type": "string", + "description": "MIME type of the note" + }, + "url": { + "type": "string", + "description": "Location of the note" + }, + "properties": { + "description": "Additional user defined note properties", + "type": "object", + "additionalProperties": true, + "example": { + "group": "My Note group", + "author": "M Jones" + } + } + } + }, + "Note": { + "allOf": [ + { + "$ref": "#/components/schemas/NoteBaseModel" + } + ], + "oneOf": [ + { + "$ref": "#/components/schemas/HrefAttribute" + }, + { + "$ref": "#/components/schemas/PositionAttribute" + } + ] + }, + "MBTilesLayersModel": { + "description": "When format='pbf' Lists the layers that appear in the vector tiles and the names and types of the attributes of features that appear in those layers.", + "type": "object", + "required": [ + "id", + "fields" + ], + "properties": { + "id": { + "type": "string", + "description": "Layer id." + }, + "fields": { + "type": "object", + "description": "A JSON object whose keys and values are the names and types of attributes available in this layer. ", + "additionalProperties": true + }, + "description": { + "type": "string", + "description": "Layer description." + }, + "minzoom": { + "type": "string", + "description": "The lowest zoom level whose tiles this layer appears in." + }, + "maxzoom": { + "type": "string", + "description": "he highest zoom level whose tiles this layer appears in." + } + } + }, + "MBTilesExtModel": { + "description": "Extends TileJSON schema with MBTiles extensions.", + "type": "object", + "required": [ + "format" + ], + "properties": { + "format": { + "type": "string", + "description": "The file format of the tile data.", + "enum": ["pbf", "jpg", "png", "webp"], + "example": "png" + }, + "type": { + "type": "string", + "description": "layer type", + "enum": ["overlay", "baselayer"] + }, + "vector_layers": { + "type": "array", + "description": "When format='pbf' Lists the layers that appear in the vector tiles and the names and types of the attributes of features that appear in those layers.", + "items": { + "$ref": "#/components/schemas/MBTilesLayersModel" + } + } + } + }, + "TileJSONModel": { + "description": "TileJSON schema model", + "type": "object", + "required": [ + "sourceType", + "tilejson", + "tiles" + ], + "properties": { + "sourceType": { + "type": "string", + "description": "Source type of chart data.", + "enum": [ + "tilejson" + ], + "default": "tilejson", + "example": "tilejson" + }, + "tilejson": { + "type": "string", + "description": "A semver.org style version number describing the version of the TileJSON spec.", + "example": "2.2.0" + }, + "tiles": { + "type": "array", + "description": "An array of chart tile endpoints {z}, {x} and {y}. The array MUST contain at least one endpoint.", + "items": { + "type": "string" + }, + "example": "http://localhost:3000/signalk/v2/api/resources/charts/islands/{z}/{x}/{y}.png" + }, + "name": { + "type": "string", + "description": "tileset name.", + "example": "Indonesia", + "default": null + }, + "description": { + "type": "string", + "description": "A text description of the tileset.", + "example": "Indonesian coastline", + "default": null + }, + "version": { + "type": "string", + "description": "A semver.org style version number defining the version of the chart content.", + "example": "1.0.0", + "default": "1.0.0" + }, + "attribution": { + "type": "string", + "description": "Contains an attribution to be displayed when the map is shown.", + "example": "OSM contributors", + "default": null + }, + "scheme": { + "type": "string", + "description": "Influences the y direction of the tile coordinates.", + "enum": ["xyz", "tms"], + "example": "xyz", + "default": "xyz" + }, + "bounds": { + "description": "The maximum extent of available chart tiles in the format left, bottom, right, top.", + "type": "array", + "items": { + "type": "number" + }, + "minItems": 4, + "maxItems": 4, + "example": [ + 172.7499244562935, + -41.27498133450632, + 173.9166560895481, + -40.70659187633642 + ] + }, + "minzoom": { + "type": "number", + "description": "An integer specifying the minimum zoom level.", + "example": 19, + "default": 0, + "minimum": 0, + "maximum": 30 + }, + "maxzoom": { + "type": "number", + "description": "An integer specifying the maximum zoom level. MUST be >= minzoom.", + "example": 27, + "default": 0, + "minimum": 0, + "maximum": 30 + }, + "center": { + "description": "Center of chart expressed as [longitude, latitude, zoom].", + "type": "array", + "items": { + "type": "number" + }, + "minItems": 3, + "maxItems": 3, + "example": [ + 172.7499244562935, + -41.27498133450632, + 8 + ] + } + } + }, + "TileSet": { + "allOf": [ + { + "$ref": "#/components/schemas/TileJSONModel" + }, + { + "$ref": "#/components/schemas/MBTilesExtModel" + } + ] + }, + "WMSModel": { + "description": "WMS and WMTS attributes", + "type": "object", + "required": [ + "sourceType", + "url", + "layers" + ], + "properties": { + "sourceType": { + "type": "string", + "description": "Source type of chart data.", + "enum": [ + "wmts", + "wms" + ], + "default": "wmts", + "example": "wms" + }, + "url": { + "type": "string", + "description": "URL to WMS / WMTS service", + "example": "http://mapserver.org/wmts" + }, + "layers": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of chart layers to display.", + "example": [ + "Restricted Areas", + "Fishing Zones" + ] + } + } + }, + "ChartBaseModel": { + "description": "Signal K Chart resource", + "type": "object", + "required": [ + "identifier" + ], + "properties": { + "identifier": { + "type": "string", + "description": "chart identifier / number", + "example": "NZ615" + }, + "scale": { + "type": "number", + "description": "chart scale", + "example": 250000 + } + } + }, + "Chart": { + "allOf": [ + { + "$ref": "#/components/schemas/ChartBaseModel" + } + ], + "oneOf": [ + { + "$ref": "#/components/schemas/TileSet" + }, + { + "$ref": "#/components/schemas/WMSModel" + } + ] + }, + "BaseResponseModel": { + "description": "base model for resource entry response", + "type": "object", + "required": [ + "timestamp", + "$source" + ], + "properties": { + "timestamp": { + "type": "string" + }, + "$source": { + "type": "string" + } + } + }, + "RouteResponseModel": { + "allOf": [ + { + "$ref": "#/components/schemas/BaseResponseModel" + }, + { + "$ref": "#/components/schemas/Route" + } + ] + }, + "WaypointResponseModel": { + "allOf": [ + { + "$ref": "#/components/schemas/BaseResponseModel" + }, + { + "$ref": "#/components/schemas/Waypoint" + } + ] + }, + "NoteResponseModel": { + "allOf": [ + { + "$ref": "#/components/schemas/BaseResponseModel" + }, + { + "$ref": "#/components/schemas/NoteBaseModel" + } + ], + "oneOf": [ + { + "$ref": "#/components/schemas/HrefAttribute" + }, + { + "$ref": "#/components/schemas/PositionAttribute" + } + ] + }, + "RegionResponseModel": { + "allOf": [ + { + "$ref": "#/components/schemas/BaseResponseModel" + }, + { + "$ref": "#/components/schemas/Region" + } + ] + }, + "ChartResponseModel": { + "allOf": [ + { + "$ref": "#/components/schemas/BaseResponseModel" + }, + { + "$ref": "#/components/schemas/Chart" + } + ] + } + }, + "responses": { + "200ActionResponse": { + "description": "PUT, DELETE OK response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "state": { + "type": "string", + "enum": [ + "COMPLETED" + ] + }, + "statusCode": { + "type": "number", + "enum": [ + 200 + ] + }, + "id": { + "$ref": "#/components/schemas/SignalKUuid" + } + }, + "required": [ + "id", + "statusCode", + "state" + ] + } + } + } + }, + "201ActionResponse": { + "description": "POST OK response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "state": { + "type": "string", + "enum": [ + "COMPLETED" + ] + }, + "statusCode": { + "type": "number", + "enum": [ + 201 + ] + }, + "id": { + "$ref": "#/components/schemas/SignalKUuid" + } + }, + "required": [ + "id", + "statusCode", + "state" + ] + } + } + } + }, + "ErrorResponse": { + "description": "Failed operation", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Request error response", + "properties": { + "state": { + "type": "string", + "enum": [ + "FAILED" + ] + }, + "statusCode": { + "type": "number", + "enum": [ + 404 + ] + }, + "message": { + "type": "string" + } + }, + "required": [ + "state", + "statusCode", + "message" + ] + } + } + } + }, + "RouteResponse": { + "description": "Route record response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RouteResponseModel" + } + } + } + }, + "WaypointResponse": { + "description": "Waypoint record response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WaypointResponseModel" + } + } + } + }, + "NoteResponse": { + "description": "Note record response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NoteResponseModel" + } + } + } + }, + "RegionResponse": { + "description": "Region record response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RegionResponseModel" + } + } + } + }, + "ChartResponse": { + "description": "Chart record response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChartResponseModel" + } + } + } + } + }, + "parameters": { + "LimitParam": { + "in": "query", + "name": "limit", + "description": "Maximum number of records to return", + "schema": { + "type": "integer", + "format": "int32", + "minimum": 1, + "example": 100 + } + }, + "DistanceParam": { + "in": "query", + "name": "distance", + "description": "Limit results to resources that fall within a square area, centered around the vessel's position (or position parameter value if supplied), the edges of which are the sepecified distance in meters from the vessel.", + "schema": { + "type": "integer", + "format": "int32", + "minimum": 100, + "example": 2000 + } + }, + "BoundingBoxParam": { + "in": "query", + "name": "bbox", + "description": "Limit results to resources that fall within the bounded area defined as lower left and upper right longitude, latatitude coordinates [lon1, lat1, lon2, lat2]", + "style": "form", + "explode": false, + "schema": { + "type": "array", + "minItems": 4, + "maxItems": 4, + "items": { + "type": "number", + "format": "float", + "minimum": -180, + "maximum": 180 + }, + "example": [ + 135.5, + -25.2, + 138.1, + -28 + ] + } + }, + "PositionParam": { + "in": "query", + "name": "position", + "description": "Location, in format [longitude, latitude], from where the distance parameter is applied.", + "style": "form", + "explode": false, + "schema": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "items": { + "type": "number", + "format": "float", + "minimum": -180, + "maximum": 180 + }, + "example": [ + 135.5, + -25.2 + ] + } + } + } + }, + "paths": { + "/resources": { + "get": { + "tags": [ + "resources" + ], + "summary": "Retrieve list of available resource types", + "responses": { + "default": { + "description": "List of avaialble resource types identified by name", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "required": [ + "$source" + ], + "properties": { + "description": { + "type": "string" + }, + "$source": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "/resources/routes": { + "get": { + "tags": [ + "routes" + ], + "summary": "Retrieve route resources", + "parameters": [ + { + "$ref": "#/components/parameters/LimitParam" + }, + { + "$ref": "#/components/parameters/DistanceParam" + }, + { + "$ref": "#/components/parameters/BoundingBoxParam" + }, + { + "$ref": "#/components/parameters/PositionParam" + } + ], + "responses": { + "default": { + "description": "List of route resources identified by their UUID", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "allOf": [ + { + "$ref": "#/components/schemas/RouteResponseModel" + } + ] + } + } + } + } + } + } + }, + "post": { + "tags": [ + "routes" + ], + "summary": "New Route", + "requestBody": { + "description": "API request payload", + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RoutePostModel" + } + } + } + }, + "responses": { + "201": { + "$ref": "#/components/responses/201ActionResponse" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + }, + "/resources/routes/{id}": { + "parameters": [ + { + "name": "id", + "in": "path", + "description": "route id", + "required": true, + "schema": { + "$ref": "#/components/schemas/SignalKUuid" + } + } + ], + "get": { + "tags": [ + "routes" + ], + "summary": "Retrieve route with supplied id", + "responses": { + "200": { + "$ref": "#/components/responses/RouteResponse" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + }, + "put": { + "tags": [ + "routes" + ], + "summary": "Add / update a new Route with supplied id", + "requestBody": { + "description": "Route resource entry", + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Route" + } + } + } + }, + "responses": { + "200": { + "$ref": "#/components/responses/200ActionResponse" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + }, + "delete": { + "tags": [ + "routes" + ], + "summary": "Remove Route with supplied id", + "responses": { + "200": { + "$ref": "#/components/responses/200ActionResponse" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + }, + "/resources/waypoints": { + "get": { + "tags": [ + "waypoints" + ], + "summary": "Retrieve waypoint resources", + "parameters": [ + { + "$ref": "#/components/parameters/LimitParam" + }, + { + "$ref": "#/components/parameters/DistanceParam" + }, + { + "$ref": "#/components/parameters/BoundingBoxParam" + }, + { + "$ref": "#/components/parameters/PositionParam" + } + ], + "responses": { + "default": { + "description": "List of waypoint resources identified by their UUID", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "allOf": [ + { + "$ref": "#/components/schemas/WaypointResponseModel" + } + ] + } + } + } + } + } + } + }, + "post": { + "tags": [ + "waypoints" + ], + "summary": "New Waypoint", + "requestBody": { + "description": "API request payload", + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WaypointPostModel" + } + } + } + }, + "responses": { + "201": { + "$ref": "#/components/responses/201ActionResponse" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + }, + "/resources/waypoints/{id}": { + "parameters": [ + { + "name": "id", + "in": "path", + "description": "waypoint id", + "required": true, + "schema": { + "$ref": "#/components/schemas/SignalKUuid" + } + } + ], + "get": { + "tags": [ + "waypoints" + ], + "summary": "Retrieve waypoint with supplied id", + "responses": { + "200": { + "$ref": "#/components/responses/WaypointResponse" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + }, + "put": { + "tags": [ + "waypoints" + ], + "summary": "Add / update a new Waypoint with supplied id", + "requestBody": { + "description": "Waypoint resource entry", + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Waypoint" + } + } + } + }, + "responses": { + "200": { + "$ref": "#/components/responses/200ActionResponse" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + }, + "delete": { + "tags": [ + "waypoints" + ], + "summary": "Remove Waypoint with supplied id", + "responses": { + "200": { + "$ref": "#/components/responses/200ActionResponse" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + }, + "/resources/regions": { + "get": { + "tags": [ + "regions" + ], + "summary": "Retrieve region resources", + "parameters": [ + { + "$ref": "#/components/parameters/LimitParam" + }, + { + "$ref": "#/components/parameters/DistanceParam" + }, + { + "$ref": "#/components/parameters/BoundingBoxParam" + }, + { + "$ref": "#/components/parameters/PositionParam" + } + ], + "responses": { + "default": { + "description": "List of region resources identified by their UUID", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "allOf": [ + { + "$ref": "#/components/schemas/RegionResponseModel" + } + ] + } + } + } + } + } + } + }, + "post": { + "tags": [ + "regions" + ], + "summary": "New Region", + "requestBody": { + "description": "API request payload", + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RegionPostModel" + } + } + } + }, + "responses": { + "201": { + "$ref": "#/components/responses/201ActionResponse" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + }, + "/resources/regions/{id}": { + "parameters": [ + { + "name": "id", + "in": "path", + "description": "region id", + "required": true, + "schema": { + "$ref": "#/components/schemas/SignalKUuid" + } + } + ], + "get": { + "tags": [ + "regions" + ], + "summary": "Retrieve region with supplied id", + "responses": { + "200": { + "$ref": "#/components/responses/RegionResponse" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + }, + "put": { + "tags": [ + "regions" + ], + "summary": "Add / update a new Region with supplied id", + "requestBody": { + "description": "Region resource entry", + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Region" + } + } + } + }, + "responses": { + "200": { + "$ref": "#/components/responses/200ActionResponse" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + }, + "delete": { + "tags": [ + "regions" + ], + "summary": "Remove Region with supplied id", + "responses": { + "200": { + "$ref": "#/components/responses/200ActionResponse" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + }, + "/resources/notes": { + "get": { + "tags": [ + "notes" + ], + "summary": "Retrieve note resources", + "parameters": [ + { + "$ref": "#/components/parameters/LimitParam" + }, + { + "$ref": "#/components/parameters/DistanceParam" + }, + { + "$ref": "#/components/parameters/BoundingBoxParam" + }, + { + "$ref": "#/components/parameters/PositionParam" + }, + { + "name": "href", + "in": "query", + "description": "Limit results to notes with matching resource reference", + "example": "/resources/waypoints/urn:mrn:signalk:uuid:ac3a3b2d-07e8-4f25-92bc-98e7c92f7f1a", + "required": false, + "explode": false, + "schema": { + "$ref": "#/components/schemas/SignalKHref" + } + } + ], + "responses": { + "default": { + "description": "List of note resources identified by their UUID", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "allOf": [ + { + "$ref": "#/components/schemas/NoteResponseModel" + } + ] + } + } + } + } + } + } + }, + "post": { + "tags": [ + "notes" + ], + "summary": "New Note", + "requestBody": { + "description": "Note resource entry", + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Note" + } + } + } + }, + "responses": { + "201": { + "$ref": "#/components/responses/201ActionResponse" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + }, + "/resources/notes/{id}": { + "parameters": [ + { + "name": "id", + "in": "path", + "description": "note id", + "required": true, + "schema": { + "$ref": "#/components/schemas/SignalKUuid" + } + } + ], + "get": { + "tags": [ + "notes" + ], + "summary": "Retrieve note with supplied id", + "responses": { + "200": { + "$ref": "#/components/responses/NoteResponse" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + }, + "put": { + "tags": [ + "notes" + ], + "summary": "Add / update a new Note with supplied id", + "requestBody": { + "description": "Note resource entry", + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Note" + } + } + } + }, + "responses": { + "200": { + "$ref": "#/components/responses/200ActionResponse" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + }, + "delete": { + "tags": [ + "notes" + ], + "summary": "Remove Note with supplied id", + "responses": { + "200": { + "$ref": "#/components/responses/200ActionResponse" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + }, + "/resources/charts": { + "get": { + "tags": [ + "charts" + ], + "summary": "Retrieve chart resources", + "responses": { + "default": { + "description": "List of chart resources identified by their UUID", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "allOf": [ + { + "$ref": "#/components/schemas/ChartResponseModel" + } + ] + } + } + } + } + } + } + }, + "post": { + "tags": [ + "charts" + ], + "summary": "New Chart", + "requestBody": { + "description": "Chart resource entry", + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Chart" + } + } + } + }, + "responses": { + "201": { + "$ref": "#/components/responses/201ActionResponse" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + }, + "/resources/charts/{id}": { + "parameters": [ + { + "name": "id", + "in": "path", + "description": "chart id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "get": { + "tags": [ + "charts" + ], + "summary": "Retrieve chart with supplied id", + "responses": { + "200": { + "$ref": "#/components/responses/ChartResponse" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + }, + "put": { + "tags": [ + "charts" + ], + "summary": "Add / update a new Chart with supplied id", + "requestBody": { + "description": "Chart resource entry", + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Chart" + } + } + } + }, + "responses": { + "200": { + "$ref": "#/components/responses/200ActionResponse" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + } + } + } diff --git a/src/api/resources/resources.ts b/src/api/resources/resources.ts new file mode 100644 index 000000000..5657f2671 --- /dev/null +++ b/src/api/resources/resources.ts @@ -0,0 +1,152 @@ +import { Position, Region, Route, Waypoint } from '@signalk/server-api' +import { getDistance, isValidCoordinate } from 'geolib' + +const coordsType = (coords: any[]) => { + if (!Array.isArray(coords) || coords.length === 0) { + throw new Error('Invalid coordinates!') + } + if (isValidCoordinate(coords[0])) { + return 'Line' + } + const ca = coords[0] + if (Array.isArray(ca) && ca.length !== 0) { + if (isValidCoordinate(ca[0])) { + return 'Polygon' + } else if (Array.isArray(ca[0])) { + return 'MultiPolygon' + } + } + return '' +} + +const processRegionCoords = (coords: any[], type: string) => { + let result = [] + if (type === 'Line') { + const tc = transformCoords(coords) + if (tc) { + result = [tc] + } else { + throw new Error('Invalid coordinates!') + } + } + if (type === 'Polygon') { + const polygon: any[] = [] + coords.forEach(line => { + const tc = transformCoords(line) + if (tc) { + polygon.push(transformCoords(line)) + } else { + throw new Error('Invalid coordinates!') + } + }) + result = polygon + } + if (type === 'MultiPolygon') { + const multipolygon: any[] = [] + coords.forEach(polygon => { + const pa: any[] = [] + polygon.forEach((line: Position[]) => { + const tc = transformCoords(line) + if (tc) { + pa.push(transformCoords(line)) + } else { + throw new Error('Invalid coordinates!') + } + }) + multipolygon.push(pa) + }) + result = multipolygon + } + return result +} + +const transformCoords = (coords: Position[]) => { + coords.forEach((p: any) => { + if (!isValidCoordinate(p)) { + throw new Error('Invalid coordinate value!') + } + }) + // ensure polygon is closed + if ( + coords[0].latitude !== coords[coords.length - 1].latitude && + coords[0].longitude !== coords[coords.length - 1].longitude + ) { + coords.push(coords[0]) + } + return coords.map((p: Position) => { + return [p.longitude, p.latitude] + }) +} + +const calculateDistance = (points: Position[]) => { + let result = 0 + for (let i = points.length - 2; i >= 0; i--) { + result += getDistance(points[i], points[i + 1]) + } + return result +} + +const FROM_POST_MAPPERS: { + [key: string]: (data: any) => any +} = { + waypoints: (data: any) => { + const { name, description, position, properties = {} } = data + const result: Waypoint = { + feature: { + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [position.longitude, position.latitude] + } + } + } + name && (result.name = name) + description && (result.description = description) + result.feature.properties = properties + return result + }, + routes: (data: any) => { + const { name, description, points, properties = {} } = data + const distance = calculateDistance(points) + const result: Route = { + feature: { + type: 'Feature', + geometry: { + type: 'LineString', + coordinates: points.map((p: Position) => { + return [p.longitude, p.latitude] + }) + } + }, + distance + } + name && (result.name = name) + description && (result.description = description) + result.feature.properties = properties + return result + }, + regions: (data: any) => { + const { name, description, points, properties = {} } = data + const cType = coordsType(points) + const result: Region = { + feature: { + type: 'Feature', + geometry: { + type: cType === 'MultiPolygon' ? (cType as any) : 'Polygon', + coordinates: [] + }, + properties: {} + } + } + + name && (result.name = name) + description && (result.description = description) + result.feature.geometry.coordinates = processRegionCoords(points, cType) + result.feature.properties = properties + return result + } +} +export const fromPostData = (type: string, data: any) => + FROM_POST_MAPPERS[type as string] + ? FROM_POST_MAPPERS[type as string](data) + : data diff --git a/src/api/resources/validate.ts b/src/api/resources/validate.ts new file mode 100644 index 000000000..a887cad06 --- /dev/null +++ b/src/api/resources/validate.ts @@ -0,0 +1,76 @@ +import { SignalKResourceType } from '@signalk/server-api' +import { buildSchemaSync } from 'api-schema-builder' +import { RESOURCES_API_PATH } from '.' +import { createDebug } from '../../debug' +import resourcesOpenApi from './openApi.json' +const debug = createDebug('signalk-server:api:resources:validate') + +class ValidationError extends Error {} + +const API_SCHEMA = buildSchemaSync(resourcesOpenApi) + +export const validate = { + resource: ( + type: SignalKResourceType, + id: string | undefined, + method: string, + value: any + ): void => { + debug(`Validating ${type} ${method} ${JSON.stringify(value)}`) + const endpoint = + API_SCHEMA[`${RESOURCES_API_PATH}/${type as string}${id ? '/:id' : ''}`][ + method.toLowerCase() + ] + if (!endpoint) { + throw new Error(`Validation: endpoint for ${type} ${method} not found`) + } + const valid = endpoint.body.validate(value) + if (valid) { + return + } else { + debug(endpoint.body.errors) + throw new ValidationError(JSON.stringify(endpoint.body.errors)) + } + }, + + query: ( + type: SignalKResourceType, + id: string | undefined, + method: string, + value: any + ): void => { + debug( + `*** Validating query params for ${type} ${method} ${JSON.stringify( + value + )}` + ) + const endpoint = + API_SCHEMA[`${RESOURCES_API_PATH}/${type as string}${id ? '/:id' : ''}`][ + method.toLowerCase() + ] + if (!endpoint) { + throw new Error(`Validation: endpoint for ${type} ${method} not found`) + } + const valid = endpoint.parameters.validate({ query: value }) + if (valid) { + return + } else { + debug(endpoint.parameters.errors) + throw new ValidationError(JSON.stringify(endpoint.parameters.errors)) + } + }, + + // returns true if id is a valid Signal K UUID + uuid: (id: string): boolean => { + const uuid = RegExp( + '^urn:mrn:signalk:uuid:[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-4[0-9A-Fa-f]{3}-[89ABab][0-9A-Fa-f]{3}-[0-9A-Fa-f]{12}$' + ) + return uuid.test(id) + }, + + // returns true if id is a valid Signal K Chart resource id + chartId: (id: string): boolean => { + const uuid = RegExp('(^[A-Za-z0-9_-]{8,}$)') + return uuid.test(id) + } +} diff --git a/src/api/swagger.ts b/src/api/swagger.ts new file mode 100644 index 000000000..4cee38948 --- /dev/null +++ b/src/api/swagger.ts @@ -0,0 +1,39 @@ +import { Request, Response } from 'express' +import swaggerUi from 'swagger-ui-express' +import { SERVERROUTESPREFIX } from '../constants' +import courseApiDoc from './course/openApi.json' +import resourcesApiDoc from './resources/openApi.json' + +const apiDocs: { + [key: string]: any +} = { + course: courseApiDoc, + resources: resourcesApiDoc +} + +export function mountSwaggerUi(app: any, path: string) { + app.use( + path, + swaggerUi.serve, + swaggerUi.setup(undefined, { + explorer: true, + swaggerOptions: { + urls: Object.keys(apiDocs).map(name => ({ + name, + url: `${SERVERROUTESPREFIX}/openapi/${name}` + })) + } + }) + ) + app.get( + `${SERVERROUTESPREFIX}/openapi/:api`, + (req: Request, res: Response) => { + if (apiDocs[req.params.api]) { + res.json(apiDocs[req.params.api]) + } else { + res.status(404) + res.send('Not found') + } + } + ) +} diff --git a/src/apidocs/openapi.json b/src/apidocs/openapi.json new file mode 100644 index 000000000..ede163edc --- /dev/null +++ b/src/apidocs/openapi.json @@ -0,0 +1,12 @@ +{ + "openapi":"3.0.2", + "info": { + "title":"API Title", + "version":"1.0" + }, + "servers": [ + {"url":"https://api.server.test/v1"} + ], + "paths": { + } +} \ No newline at end of file diff --git a/src/config/config.ts b/src/config/config.ts index 28243c5db..668bda48d 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -27,6 +27,12 @@ const debug = createDebug('signalk-server:config') let disableWriteSettings = false +// use dynamic path so that ts compiler does not detect this +// json file, as ts compile needs to copy all (other) used +// json files under /lib +// tslint:disable-next-line +const packageJson = require('../../' + 'package.json') + export interface Config { getExternalHostname: () => string getExternalPort: (config: Config) => number @@ -82,14 +88,17 @@ export function load(app: ConfigApp) { debug('appPath:' + config.appPath) try { - const pkg = require('../../package.json') - config.name = pkg.name - config.author = pkg.author - config.contributors = pkg.contributors - config.version = pkg.version - config.description = pkg.description - - checkPackageVersion('@signalk/server-admin-ui', pkg, app.config.appPath) + config.name = packageJson.name + config.author = packageJson.author + config.contributors = packageJson.contributors + config.version = packageJson.version + config.description = packageJson.description + + checkPackageVersion( + '@signalk/server-admin-ui', + packageJson, + app.config.appPath + ) } catch (err) { console.error('error parsing package.json', err) process.exit(1) @@ -489,5 +498,6 @@ module.exports = { writeDefaultsFile, readDefaultsFile, sendBaseDeltas, - writeBaseDeltasFile + writeBaseDeltasFile, + package: packageJson } diff --git a/src/index.ts b/src/index.ts index 816df5389..1d5817b2e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -27,6 +27,7 @@ import http from 'http' import https from 'https' import _ from 'lodash' import path from 'path' +import { startApis } from './api' import { SelfIdentity, ServerApp, SignalKMessageHub, WithConfig } from './app' import { ConfigApp, load, sendBaseDeltas } from './config/config' import { createDebug } from './debug' @@ -347,8 +348,8 @@ class Server { app.intervals.push(startDeltaStatistics(app)) - return new Promise((resolve, reject) => { - createServer(app, (err, server) => { + return new Promise(async (resolve, reject) => { + createServer(app, async (err, server) => { if (err) { reject(err) return @@ -362,6 +363,7 @@ class Server { sendBaseDeltas((app as unknown) as ConfigApp) + await startApis(app) startInterfaces(app) startMdns(app) app.providers = require('./pipedproviders')(app).start() diff --git a/src/interfaces/plugins.ts b/src/interfaces/plugins.ts index c62fbade7..8ffcdf744 100644 --- a/src/interfaces/plugins.ts +++ b/src/interfaces/plugins.ts @@ -16,7 +16,8 @@ import { PluginServerApp, PropertyValues, - PropertyValuesCallback + PropertyValuesCallback, + ResourceProvider } from '@signalk/server-api' // @ts-ignore import { getLogger } from '@signalk/streams/logging' @@ -24,6 +25,7 @@ import express, { Request, Response } from 'express' import fs from 'fs' import _ from 'lodash' import path from 'path' +import { ResourcesApi } from '../api/resources' import { SERVERROUTESPREFIX } from '../constants' import { createDebug } from '../debug' import { DeltaInputHandler } from '../deltachain' @@ -473,6 +475,7 @@ module.exports = (theApp: any) => { console.error(`${plugin.id}:no configuration data`) safeConfiguration = {} } + onStopHandlers[plugin.id] = [() => app.resourcesApi.unRegister(plugin.id)] plugin.start(safeConfiguration, restart) debug('Started plugin ' + plugin.name) setPluginStartedMessage(plugin) @@ -545,6 +548,13 @@ module.exports = (theApp: any) => { getMetadata }) appCopy.putPath = putPath + + const resourcesApi: ResourcesApi = app.resourcesApi + _.omit(appCopy, 'resourcesApi') // don't expose the actual resource api manager + appCopy.registerResourceProvider = (provider: ResourceProvider) => { + resourcesApi.register(plugin.id, provider) + } + try { const pluginConstructor: ( app: ServerAPI @@ -556,7 +566,6 @@ module.exports = (theApp: any) => { app.setProviderError(packageName, `Failed to start: ${e.message}`) return } - onStopHandlers[plugin.id] = [] if (app.pluginsMap[plugin.id]) { console.log( diff --git a/src/interfaces/rest.js b/src/interfaces/rest.js index 72bea7075..2958cd2ab 100644 --- a/src/interfaces/rest.js +++ b/src/interfaces/rest.js @@ -21,6 +21,7 @@ const { getMetadata, getUnits } = require('@signalk/signalk-schema') const ports = require('../ports') const geolib = require('geolib') const _ = require('lodash') +const pkg = require('../config/config').package const iso8601rexexp = /^(-?(?:[1-9][0-9]*)?[0-9]{4})-(1[0-2]|0[1-9])-(3[01]|0[1-9]|[12][0-9])T(2[0-3]|[01][0-9]):([0-5][0-9]):([0-5][0-9])(\.[0-9]+)?Z$/ @@ -188,4 +189,4 @@ module.exports = function(app) { } } -const getVersion = () => require('../../package.json').version +const getVersion = () => pkg.version diff --git a/src/security.ts b/src/security.ts index ccffd8692..75a60fbd8 100644 --- a/src/security.ts +++ b/src/security.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { Request } from 'express' import { chmodSync, existsSync, @@ -44,6 +45,12 @@ export interface SecurityStrategy { configFromArguments: boolean securityConfig: any requestAccess: (config: any, request: any, ip: any, updateCb: any) => any + shouldAllowPut: ( + req: Request, + context: string, + source: any, + path: string + ) => boolean } export class InvalidTokenError extends Error { diff --git a/src/serverroutes.js b/src/serverroutes.js index bb9b2424b..793528f95 100644 --- a/src/serverroutes.js +++ b/src/serverroutes.js @@ -39,6 +39,7 @@ const ncp = require('ncp').ncp const defaultSecurityStrategy = './tokensecurity' const skPrefix = '/signalk/v1' import { SERVERROUTESPREFIX } from './constants' +import { mountSwaggerUi } from './api/swagger' module.exports = function(app, saveSecurityConfig, getSecurityConfig) { let securityWasEnabled @@ -52,6 +53,9 @@ module.exports = function(app, saveSecurityConfig, getSecurityConfig) { ) } + // mount before the main /admin + mountSwaggerUi(app, '/admin/openapi') + app.get('/admin/', (req, res) => { fs.readFile( path.join( diff --git a/src/serverstate/store.ts b/src/serverstate/store.ts new file mode 100644 index 000000000..d6db82f7b --- /dev/null +++ b/src/serverstate/store.ts @@ -0,0 +1,52 @@ +import { constants } from 'fs' +import { access, mkdir, readFile, writeFile } from 'fs/promises' +import path from 'path' + +export class Store { + private filePath = '' + private fileName = '' + + constructor(filePath: string, fileName = 'settings.json') { + this.filePath = filePath + this.fileName = fileName + this.init().catch(error => { + console.log( + `Could not initialise ${path.join(this.filePath, this.fileName)}` + ) + console.log(error) + }) + } + + async read(): Promise { + try { + const data = await readFile( + path.join(this.filePath, this.fileName), + 'utf8' + ) + return JSON.parse(data) + } catch (error) { + throw error + } + } + + write(data: any): Promise { + return writeFile( + path.join(this.filePath, this.fileName), + JSON.stringify(data) + ) + } + + private async init() { + try { + /* tslint:disable:no-bitwise */ + await access(this.filePath, constants.R_OK | constants.W_OK) + /* tslint:enable:no-bitwise */ + } catch (error) { + try { + await mkdir(this.filePath, { recursive: true }) + } catch (error) { + console.log(`Error: Unable to create ${this.filePath}`) + } + } + } +} diff --git a/src/subscriptionmanager.ts b/src/subscriptionmanager.ts index 68d56e610..48ee40d98 100644 --- a/src/subscriptionmanager.ts +++ b/src/subscriptionmanager.ts @@ -14,13 +14,14 @@ * limitations under the License. */ +import { Position } from '@signalk/server-api' import Bacon from 'baconjs' import { isPointWithinRadius } from 'geolib' import _, { forOwn, get, isString } from 'lodash' import { createDebug } from './debug' import DeltaCache from './deltacache' import { toDelta } from './streambundle' -import { ContextMatcher, Position, Unsubscribes, WithContext } from './types' +import { ContextMatcher, Unsubscribes, WithContext } from './types' const debug = createDebug('signalk-server:subscriptionmanager') interface BusesMap { diff --git a/src/types.ts b/src/types.ts index d9dbf8f87..27fe4489b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -72,7 +72,3 @@ export type Delta = any export type Path = string export type Context = string export type Value = object | number | string | null -export interface Position { - latitude: number - longitude: number -} diff --git a/src/types/freeport-promise/index.d.ts b/src/types/freeport-promise/index.d.ts new file mode 100644 index 000000000..dcbf20bd3 --- /dev/null +++ b/src/types/freeport-promise/index.d.ts @@ -0,0 +1 @@ +declare module 'freeport-promise' \ No newline at end of file diff --git a/test/course.ts b/test/course.ts new file mode 100644 index 000000000..99ef37214 --- /dev/null +++ b/test/course.ts @@ -0,0 +1,572 @@ +import { strict as assert } from 'assert' +import chai from 'chai' +import resourcesOpenApi from '../src/api/resources/openApi.json' +import { deltaHasPathValue, startServer } from './ts-servertestutilities' +chai.should() + +describe('Course Api', () => { + it('can set course destination as position', async function() { + const { + createWsPromiser, + selfGetJson, + selfPut, + sendDelta, + stop + } = await startServer() + const wsPromiser = createWsPromiser() + const self = JSON.parse(await wsPromiser.nthMessage(1)).self + + sendDelta('navigation.position', { latitude: -35.45, longitude: 138.0 }) + await wsPromiser.nthMessage(2) + + await selfPut('navigation/course/destination', { + position: { latitude: -35.5, longitude: 138.7 } + }).then(response => response.status.should.equal(200)) + + const courseDelta = JSON.parse(await wsPromiser.nthMessage(3)) + courseDelta.context.should.equal(self) + + const expectedPathValues = [ + { + path: 'navigation.course.activeRoute.href', + value: null + }, + { + path: 'navigation.course.activeRoute.startTime', + value: null + }, + { + path: 'navigation.course.activeRoute.pointIndex', + value: 0 + }, + { + path: 'navigation.course.activeRoute.pointTotal', + value: 0 + }, + { + path: 'navigation.course.activeRoute.reverse', + value: false + }, + { + path: 'navigation.course.nextPoint.href', + value: null + }, + { + path: 'navigation.course.nextPoint.position', + value: { + latitude: -35.5, + longitude: 138.7 + } + }, + { + path: 'navigation.course.nextPoint.type', + value: 'Location' + }, + { + path: 'navigation.course.nextPoint.arrivalCircle', + value: 0 + }, + { + path: 'navigation.course.previousPoint.position', + value: { + latitude: -35.45, + longitude: 138 + } + }, + { + path: 'navigation.course.previousPoint.type', + value: 'VesselPosition' + } + ] + expectedPathValues.forEach(({ path, value }) => + deltaHasPathValue(courseDelta, path, value) + ) + + await selfGetJson('navigation/course').then(data => { + data.should.deep.equal({ + activeRoute: { + href: null, + startTime: null, + pointIndex: 0, + pointTotal: 0, + reverse: false + }, + nextPoint: { + href: null, + type: 'Location', + position: { latitude: -35.5, longitude: 138.7 }, + arrivalCircle: 0 + }, + previousPoint: { + href: null, + type: 'VesselPosition', + position: { latitude: -35.45, longitude: 138 } + } + }) + }) + await stop() + }) + + it('can not set course destination as nonexistent waypoint or bad payload', async function() { + const { createWsPromiser, selfPut, sendDelta, stop } = await startServer() + + const wsPromiser = createWsPromiser() + await wsPromiser.nthMessage(1) // hello + + sendDelta('navigation.position', { latitude: -35.45, longitude: 138.0 }) + await wsPromiser.nthMessage(2) // position + + const validDestinationPosition = { latitude: -35.5, longitude: 138.7 } + + await selfPut('navigation/course/destination', { + position: validDestinationPosition + }).then(response => response.status.should.equal(200)) + + const courseDelta = JSON.parse(await wsPromiser.nthMessage(3)) + deltaHasPathValue( + courseDelta, + 'navigation.course.nextPoint.position', + validDestinationPosition + ) + + await selfPut('navigation/course/destination', { + href: + '/resources/waypoints/urn:mrn:signalk:uuid:07894aba-f151-4099-aa4f-5e5773734b95' + }).then(response => response.status.should.equal(400)) + await assert.rejects(wsPromiser.nthMessage(4)) + + await selfPut('navigation/course/destination', { + hrefff: 'dummy data' + }).then(response => response.status.should.equal(400)) + await assert.rejects(wsPromiser.nthMessage(4)) + + await selfPut('navigation/course/destination', { + position: { latitude: -35.5 } + }).then(response => response.status.should.equal(400)) + await assert.rejects(wsPromiser.nthMessage(4)) + + await stop() + }) + + it('can set course destination as waypoint with arrivalcircle and then clear destination', async function() { + const { + createWsPromiser, + post, + selfDelete, + selfGetJson, + selfPut, + sendDelta, + stop + } = await startServer() + const vesselPosition = { latitude: -35.45, longitude: 138.0 } + sendDelta('navigation.position', vesselPosition) + + const destination = { + latitude: 60.1699, + longitude: 24.9384 + } + const { id } = await post('/resources/waypoints', { + position: destination + }).then(response => { + response.status.should.equal(201) + return response.json() + }) + id.length.should.equal( + 'urn:mrn:signalk:uuid:ac3a3b2d-07e8-4f25-92bc-98e7c92f7f1a'.length + ) + const href = `/resources/waypoints/${id}` + + const wsPromiser = createWsPromiser() + const self = JSON.parse(await wsPromiser.nthMessage(1)).self + + await selfPut('navigation/course/destination', { + href, + arrivalCircle: 99 + }).then(response => response.status.should.equal(200)) + + const courseDelta = JSON.parse(await wsPromiser.nthMessage(2)) + courseDelta.context.should.equal(self) + + let expectedPathValues = [ + { path: 'navigation.course.activeRoute.href', value: null }, + { path: 'navigation.course.activeRoute.startTime', value: null }, + { path: 'navigation.course.activeRoute.pointIndex', value: 0 }, + { path: 'navigation.course.activeRoute.pointTotal', value: 0 }, + { path: 'navigation.course.activeRoute.reverse', value: false }, + { + path: 'navigation.course.nextPoint.href', + value: href + }, + { + path: 'navigation.course.nextPoint.position', + value: { latitude: 60.1699, longitude: 24.9384 } + }, + { path: 'navigation.course.nextPoint.type', value: 'Waypoint' }, + { path: 'navigation.course.nextPoint.arrivalCircle', value: 99 }, + { + path: 'navigation.course.previousPoint.position', + value: { latitude: -35.45, longitude: 138 } + }, + { + path: 'navigation.course.previousPoint.type', + value: 'VesselPosition' + } + ] + expectedPathValues.forEach(({ path, value }) => + deltaHasPathValue(courseDelta, path, value) + ) + + await selfGetJson('navigation/course').then(data => { + data.should.deep.equal({ + activeRoute: { + href: null, + startTime: null, + pointIndex: 0, + pointTotal: 0, + reverse: false + }, + nextPoint: { + href, + type: 'Waypoint', + position: destination, + arrivalCircle: 99 + }, + previousPoint: { + href: null, + type: 'VesselPosition', + position: vesselPosition + } + }) + }) + + await selfDelete('navigation/course/destination').then(response => + response.status.should.equal(200) + ) + const destinationClearedDelta = JSON.parse(await wsPromiser.nthMessage(3)) + expectedPathValues = [ + { + path: 'navigation.course.activeRoute.href', + value: null + }, + { + path: 'navigation.course.activeRoute.startTime', + value: null + }, + { + path: 'navigation.course.activeRoute.pointIndex', + value: 0 + }, + { + path: 'navigation.course.activeRoute.pointTotal', + value: 0 + }, + { + path: 'navigation.course.activeRoute.reverse', + value: false + }, + { + path: 'navigation.course.nextPoint.href', + value: null + }, + { + path: 'navigation.course.nextPoint.position', + value: null + }, + { + path: 'navigation.course.nextPoint.type', + value: null + }, + { + path: 'navigation.course.nextPoint.arrivalCircle', + value: 99 + }, + { + path: 'navigation.course.previousPoint.position', + value: null + }, + { + path: 'navigation.course.previousPoint.type', + value: null + } + ] + expectedPathValues.forEach(({ path, value }) => + deltaHasPathValue(destinationClearedDelta, path, value) + ) + + await selfGetJson('navigation/course').then(data => { + data.should.deep.equal({ + activeRoute: { + href: null, + startTime: null, + pointIndex: 0, + pointTotal: 0, + reverse: false + }, + nextPoint: { + href: null, + type: null, + position: null, + arrivalCircle: 99 + }, + previousPoint: { + href: null, + type: null, + position: null + } + }) + }) + + stop() + }) + + it('can activate route and manipulate it', async function() { + const { + createWsPromiser, + post, + selfGetJson, + selfPut, + sendDelta, + stop + } = await startServer() + const vesselPosition = { latitude: -35.45, longitude: 138.0 } + sendDelta('navigation.position', vesselPosition) + + const points = + resourcesOpenApi.components.schemas.SignalKPositionArray.example + + const { id } = await post('/resources/routes', { + points + }).then(response => { + response.status.should.equal(201) + return response.json() + }) + id.length.should.equal( + 'urn:mrn:signalk:uuid:ac3a3b2d-07e8-4f25-92bc-98e7c92f7f1a'.length + ) + const href = `/resources/routes/${id}` + + const wsPromiser = createWsPromiser() + const self = JSON.parse(await wsPromiser.nthMessage(1)).self + + await selfPut('navigation/course/activeRoute', { + href + }).then(response => response.status.should.equal(200)) + + const courseDelta = JSON.parse(await wsPromiser.nthMessage(2)) + courseDelta.context.should.equal(self) + + const expectedPathValues = [ + { + path: 'navigation.course.activeRoute.href', + value: href + }, + { + path: 'navigation.course.activeRoute.pointIndex', + value: 0 + }, + { + path: 'navigation.course.activeRoute.pointTotal', + value: 3 + }, + { + path: 'navigation.course.activeRoute.reverse', + value: false + }, + { + path: 'navigation.course.nextPoint.href', + value: null + }, + { + path: 'navigation.course.nextPoint.position', + value: { + latitude: 65.4567, + longitude: 3.3452 + } + }, + { + path: 'navigation.course.nextPoint.type', + value: 'RoutePoint' + }, + { + path: 'navigation.course.nextPoint.arrivalCircle', + value: 0 + }, + { + path: 'navigation.course.previousPoint.position', + value: { + latitude: -35.45, + longitude: 138 + } + }, + { + path: 'navigation.course.previousPoint.type', + value: 'VesselPosition' + } + ] + expectedPathValues.forEach(({ path, value }) => + deltaHasPathValue(courseDelta, path, value) + ) + courseDelta.updates[0].values.find( + (x: any) => x.path === 'navigation.course.activeRoute.startTime' + ).should.not.be.undefined + + await selfGetJson('navigation/course').then(data => { + delete data.activeRoute.startTime + data.should.deep.equal({ + activeRoute: { + href, + pointIndex: 0, + pointTotal: 3, + reverse: false + }, + nextPoint: { + href: null, + position: points[0], + type: 'RoutePoint', + arrivalCircle: 0 + }, + previousPoint: { + href: null, + type: 'VesselPosition', + position: vesselPosition + } + }) + }) + + await selfPut('navigation/course/activeRoute/nextPoint', { + value: 1 + }).then(response => response.status.should.equal(200)) + await selfGetJson('navigation/course').then(data => + data.activeRoute.pointIndex.should.equal(1) + ) + + await selfPut('navigation/course/activeRoute/nextPoint', { + value: 100 + }).then(response => response.status.should.equal(400)) + await selfGetJson('navigation/course').then(data => + data.activeRoute.pointIndex.should.equal(1) + ) + + await selfPut('navigation/course/activeRoute/nextPoint', { + value: -1 + }).then(response => response.status.should.equal(200)) + await selfGetJson('navigation/course').then(data => + data.activeRoute.pointIndex.should.equal(0) + ) + + await selfPut('navigation/course/activeRoute/pointIndex', { + value: 2 + }).then(response => response.status.should.equal(200)) + await selfGetJson('navigation/course').then(data => + data.activeRoute.pointIndex.should.equal(2) + ) + + await selfPut('navigation/course/activeRoute', { + href, + reverse: true + }).then(response => response.status.should.equal(200)) + await selfGetJson('navigation/course').then(data => + data.nextPoint.position.latitude.should.equal( + points[points.length - 1].latitude + ) + ) + await selfPut('navigation/course/activeRoute/nextPoint', { + value: 1 + }).then(response => response.status.should.equal(200)) + await selfGetJson('navigation/course').then(data => { + data.nextPoint.position.latitude.should.equal(points[1].latitude) + data.previousPoint.position.latitude.should.equal( + points[points.length - 1].latitude + ) + }) + + stop() + }) + + it('can set arrivalCircle', async function() { + const { createWsPromiser, selfGetJson, selfPut, stop } = await startServer() + + const wsPromiser = createWsPromiser() + await wsPromiser.nthMessage(1) + + await selfPut('navigation/course/arrivalCircle', { + value: 98 + }).then(response => response.status.should.equal(200)) + + const courseDelta = JSON.parse(await wsPromiser.nthMessage(2)) + + const expectedPathValues = [ + { + path: 'navigation.course.activeRoute.href', + value: null + }, + { + path: 'navigation.course.activeRoute.startTime', + value: null + }, + { + path: 'navigation.course.activeRoute.pointIndex', + value: 0 + }, + { + path: 'navigation.course.activeRoute.pointTotal', + value: 0 + }, + { + path: 'navigation.course.activeRoute.reverse', + value: false + }, + { + path: 'navigation.course.nextPoint.href', + value: null + }, + { + path: 'navigation.course.nextPoint.position', + value: null + }, + { + path: 'navigation.course.nextPoint.type', + value: null + }, + { + path: 'navigation.course.nextPoint.arrivalCircle', + value: 98 + }, + { + path: 'navigation.course.previousPoint.position', + value: null + }, + { + path: 'navigation.course.previousPoint.type', + value: null + } + ] + expectedPathValues.forEach(({ path, value }) => + deltaHasPathValue(courseDelta, path, value) + ) + + await selfGetJson('navigation/course').then(data => { + data.should.deep.equal({ + activeRoute: { + href: null, + startTime: null, + pointIndex: 0, + pointTotal: 0, + reverse: false + }, + nextPoint: { + href: null, + type: null, + position: null, + arrivalCircle: 98 + }, + previousPoint: { + href: null, + type: null, + position: null + } + }) + }) + stop() + }) +}) diff --git a/test/deltacache.js b/test/deltacache.js index 815036d6f..69f1ab179 100644 --- a/test/deltacache.js +++ b/test/deltacache.js @@ -201,6 +201,7 @@ describe('deltacache', () => { self.should.have.nested.property('name', 'TestBoat') delete self.imaginary + delete self.navigation.course //FIXME until in schema fullTree.should.be.validSignalK }) }) @@ -210,7 +211,8 @@ describe('deltacache', () => { return serverP.then(server => { return deltaP.then(() => { var deltas = server.app.deltaCache.getCachedDeltas(delta => true, null) - assert(deltas.length == expectedOrder.length) + const COURSE_API_INITIAL_DELTAS = 11 + deltas.length.should.equal(expectedOrder.length + COURSE_API_INITIAL_DELTAS) for (var i = 0; i < expectedOrder.length; i++) { if (!deltas[i].updates[0].meta) { deltas[i].updates[0].values[0].path.should.equal( @@ -232,8 +234,10 @@ describe('deltacache', () => { const fullTree = server.app.deltaCache.buildFull(null, ['sources']) const self = _.get(fullTree, fullTree.self) delete self.imaginary + delete self.navigation.course //FIXME until in schema fullTree.should.be.validSignalK fullTree.sources.should.deep.equal({ + courseApi: {}, defaults: {}, deltaFromHttp: {} }) diff --git a/test/multiple-values.js b/test/multiple-values.js index 6cab96b00..706534c06 100644 --- a/test/multiple-values.js +++ b/test/multiple-values.js @@ -71,6 +71,7 @@ describe('Server', function () { 'navigation.trip.log.$source', 'deltaFromHttp.115' ) + delete treeAfterFirstDelta.vessels[uuid].navigation.course //FIXME until in schema treeAfterFirstDelta.should.be.validSignalK delta.updates[0].values[0].value = 1 @@ -88,6 +89,7 @@ describe('Server', function () { 'navigation.trip.log.$source', 'deltaFromHttp.115' ) + delete treeAfterSecondDelta.vessels[uuid].navigation.course //FIXME until in schema treeAfterSecondDelta.should.be.validSignalK delta.updates[0].values[0].value = 2 @@ -112,6 +114,7 @@ describe('Server', function () { treeAfterOtherSourceDelta.vessels[uuid].navigation.trip.log.values[ 'deltaFromHttp.116' ].value.should.equal(2) + delete treeAfterOtherSourceDelta.vessels[uuid].navigation.course //FIXME until in schema treeAfterOtherSourceDelta.should.be.validSignalK }) }).timeout(4000) diff --git a/test/plugin-test-config/node_modules/testplugin/index.js b/test/plugin-test-config/node_modules/testplugin/index.js index 95f1c6d0b..ac9363c71 100644 --- a/test/plugin-test-config/node_modules/testplugin/index.js +++ b/test/plugin-test-config/node_modules/testplugin/index.js @@ -34,6 +34,38 @@ module.exports = function(app) { } next(delta) }) + + app.registerResourceProvider({ + type: 'test-custom-resourcetype', + methods: { + listResources: (type, query) => { + console.log(`test plugin listResources(${type}, ${query})`) + return Promise.resolve({ + dummyResourceId: { + value: `test plugin listResources(${type}, ${query})` + } + }) + }, + getResource: (type, id) => { + console.log(`test plugin getResource(${type}, ${id})`) + return Promise.resolve({ + value: `test plugin listResources(${type}, ${id})` + }) + }, + setResource: (type, id, value) => { + console.log(`test plugin setResource(${type}, ${id}, ${value})`) + return Promise.resolve({ + dummyResourceId: { + value: `test plugin listResources(${type}, ${id}, ${value})` + } + }) + }, + deleteResource: (type, id) => { + console.log(`test plugin deleteResource(${type}, ${id})`) + return Promise.resolve() + } + } + }) }, stop: function() { this.started = false diff --git a/test/resources.ts b/test/resources.ts new file mode 100644 index 000000000..e210f4f0d --- /dev/null +++ b/test/resources.ts @@ -0,0 +1,77 @@ +import { Waypoint } from '@signalk/server-api' +import chai from 'chai' +import { v4 as uuidv4 } from 'uuid' +import { startServer } from './ts-servertestutilities' +chai.should() + +const UUID_PREFIX = 'urn:mrn:signalk:uuid:' +export const skUuid = () => `${UUID_PREFIX}${uuidv4()}` + +describe('Resources Api', () => { + it('can put and get a waypoint', async function() { + const { createWsPromiser, get, put, stop } = await startServer() + + const wsPromiser = createWsPromiser() + await wsPromiser.nthMessage(1) + + const waypoint: Waypoint = { + feature: { + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [60.151672, 24.891637] + } + } + } + const resId = skUuid() + await put(`/resources/waypoints/${resId}`, waypoint).then(response => { + // response.json().then(x => console.log(x)) + response.status.should.equal(200) + }) + + const resourceDelta = JSON.parse(await wsPromiser.nthMessage(2)) + const { path, value } = resourceDelta.updates[0].values[0] + path.should.equal(`resources.waypoints.${resId}`) + value.should.deep.equal(waypoint) + ;(waypoint as any).$source = 'resources-provider' + await get(`/resources/waypoints/${resId}`) + .then(response => { + response.status.should.equal(200) + return response.json() + }) + .then(resData => { + delete resData.timestamp + resData.should.deep.equal(waypoint) + }) + + stop() + }) + + it('bbox search works for waypoints', async function() { + const { createWsPromiser, get, post, stop } = await startServer() + + const resourceIds = await Promise.all( + [ + [60.151672, 24.891637], + [60.251672, 24.891637], + [60.151672, 24.991637] + ].map(([latitude, longitude]) => { + return post(`/resources/waypoints/`, { + position: { + longitude, + latitude + } + }) + .then(r => r.json()) + .then((r: any) => r.id) + }) + ) + await get('/resources/waypoints?bbox=[24.8,60.16,24.899,60.3]') + .then(r => r.json()) + .then(r => { + const returnedIds = Object.keys(r) + returnedIds.length.should.equal(1) + returnedIds[0].should.equal(resourceIds[1]) + }) + }) +}) diff --git a/test/servertestutilities.js b/test/servertestutilities.js index cbd69be05..bce553413 100755 --- a/test/servertestutilities.js +++ b/test/servertestutilities.js @@ -33,12 +33,13 @@ const defaultConfig = { } } -function WsPromiser (url) { +function WsPromiser (url, timeout = 250) { this.ws = new WebSocket(url) this.ws.on('message', this.onMessage.bind(this)) this.callees = [] this.receivedMessagePromisers = [] this.messageCount = 0 + this.timeout = timeout } WsPromiser.prototype.nextMsg = function () { @@ -47,7 +48,7 @@ WsPromiser.prototype.nextMsg = function () { callees.push(resolve) setTimeout(_ => { resolve('timeout') - }, 250) + }, this.timeout) }) } @@ -100,9 +101,14 @@ const LIMITED_USER_PASSWORD = 'verylimited' const ADMIN_USER_NAME = 'adminuser' const ADMIN_USER_PASSWORD = 'admin' +const serverTestConfigDirectory = () => require('path').join( + __dirname, + 'server-test-config' +) module.exports = { WsPromiser: WsPromiser, + serverTestConfigDirectory, sendDelta: (delta, deltaUrl) => { return fetch(deltaUrl, { method: 'POST', body: JSON.stringify(delta), headers: { 'Content-Type': 'application/json' } }) }, @@ -124,10 +130,7 @@ module.exports = { } } - process.env.SIGNALK_NODE_CONFIG_DIR = require('path').join( - __dirname, - 'server-test-config' - ) + process.env.SIGNALK_NODE_CONFIG_DIR = serverTestConfigDirectory() process.env.SIGNALK_DISABLE_SERVER_UPDATES = "true" const server = new Server(props) diff --git a/test/subscriptions.js b/test/subscriptions.js index cfd3f50a0..c08e586d0 100644 --- a/test/subscriptions.js +++ b/test/subscriptions.js @@ -181,12 +181,13 @@ describe('Subscriptions', _ => { await sendDelta(getDelta({ context: self }), deltaUrl) await sendDelta(getDelta({ context: 'vessels.othervessel' }), deltaUrl) - //skip 2nd message that is delta from defaults - const echoedDelta = await wsPromiser.nthMessage(3) + //2nd message is delta from defaults + //3-13 messages are null course delta from courseApi + const echoedDelta = await wsPromiser.nthMessage(14) assert(JSON.parse(echoedDelta).updates[0].source.pgn === 128275) try { - await wsPromiser.nthMessage(4) + await wsPromiser.nthMessage(15) throw new Error('no more messages should arrive') } catch (e) { assert.strictEqual(e, 'timeout') @@ -213,10 +214,10 @@ describe('Subscriptions', _ => { await sendDelta(getDelta({ context: 'vessels.othervessel' }), deltaUrl) //skip 2nd message that is delta from defaults - const echoedSelfDelta = await wsPromiser.nthMessage(3) + const echoedSelfDelta = await wsPromiser.nthMessage(14) assert(JSON.parse(echoedSelfDelta).updates[0].source.pgn === 128275) - const echoedOtherDelta = await wsPromiser.nthMessage(4) + const echoedOtherDelta = await wsPromiser.nthMessage(15) assert( JSON.parse(echoedOtherDelta).context === 'vessels.othervessel', 'Sends other vessel data' diff --git a/test/ts-servertestutilities.ts b/test/ts-servertestutilities.ts new file mode 100644 index 000000000..ef8f6c4d0 --- /dev/null +++ b/test/ts-servertestutilities.ts @@ -0,0 +1,95 @@ +import freeport from 'freeport-promise' +import fetch from 'node-fetch' +import path from 'path' +import rmfr from 'rmfr' +import { + sendDelta, + serverTestConfigDirectory, + startServerP, + WsPromiser +} from './servertestutilities' +import { expect } from 'chai' + +const emptyConfigDirectory = () => + Promise.all( + ['serverstate/course', 'resources', 'plugin-config-data', 'baseDeltas.json'] + .map(subDir => path.join(serverTestConfigDirectory(), subDir)) + .map(dir => rmfr(dir).then(() => console.error(dir))) + ) + +export const startServer = async () => { + const port = await freeport() + const host = 'http://localhost:' + port + const sendDeltaUrl = host + '/signalk/v1/api/_test/delta' + const api = host + '/signalk/v2/api' + + await emptyConfigDirectory() + const server = await startServerP(port, false, { + settings: { + interfaces: { + plugins: true + } + } + }) + return { + createWsPromiser: () => + new WsPromiser( + 'ws://localhost:' + + port + + '/signalk/v1/stream?subscribe=self&metaDeltas=none&sendCachedValues=false' + ), + selfPut: (path: string, body: object) => + fetch(`${api}/vessels/self/${path}`, { + method: 'PUT', + body: JSON.stringify(body), + headers: { 'Content-Type': 'application/json' } + }), + selfDelete: (path: string) => + fetch(`${api}/vessels/self/${path}`, { + method: 'DELETE' + }), + get: (path: string) => fetch(`${api}${path}`), + post: (path: string, body: object) => + fetch(`${api}${path}`, { + method: 'POST', + body: JSON.stringify(body), + headers: { 'Content-Type': 'application/json' } + }), + put: (path: string, body: object) => + fetch(`${api}${path}`, { + method: 'PUT', + body: JSON.stringify(body), + headers: { 'Content-Type': 'application/json' } + }), + selfGetJson: (path: string) => + fetch(`${api}/vessels/self/${path}`).then(r => r.json()), + sendDelta: (path: string, value: any) => + sendDelta( + { + updates: [ + { + values: [ + { + path, + value + } + ] + } + ] + }, + sendDeltaUrl + ), + stop: () => server.stop() + } +} + +export const deltaHasPathValue = (delta: any, path: string, value: any) => { + try { + const pathValue = delta.updates[0].values.find((x: any) => x.path === path) + expect(pathValue.value).to.deep.equal(value) + } catch (e) { + throw new Error( + `No such pathValue ${path}:${JSON.stringify(value)} in ${JSON.stringify(delta, null, 2)}` + ) + } +} diff --git a/tsconfig.json b/tsconfig.json index aac53bfa4..f47786721 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,7 +5,10 @@ "outDir": "./lib", "esModuleInterop": true, "strict": true, - "allowJs": true + "allowJs": true, + "typeRoots": ["./src/types", "./node_modules/@types"], + "resolveJsonModule": true, + "rootDir": "./src" }, "include": [ "./src/**/*" From e8a57128f323a11c50e9a0d7dd795cd17545cfda Mon Sep 17 00:00:00 2001 From: Teppo Kurki Date: Tue, 22 Mar 2022 22:33:58 +0200 Subject: [PATCH 02/63] chore: add resource-provider-plugin --- package.json | 3 +- packages/resources-provider-plugin/.gitignore | 3 + packages/resources-provider-plugin/.npmignore | 4 + .../.prettierrc.json | 5 + .../resources-provider-plugin/CHANGELOG.md | 40 +++ packages/resources-provider-plugin/LICENSE | 201 +++++++++++++ packages/resources-provider-plugin/README.md | 77 +++++ .../resources-provider-plugin/package.json | 47 +++ .../src/@types/geojson-validation.d.ts | 1 + .../src/@types/geolib.d.ts | 1 + .../resources-provider-plugin/src/index.ts | 284 ++++++++++++++++++ .../src/lib/filestorage.ts | 213 +++++++++++++ .../src/lib/utils.ts | 168 +++++++++++ .../src/types/index.ts | 1 + .../src/types/store.ts | 18 ++ .../resources-provider-plugin/tsconfig.json | 30 ++ .../src/{types => @types}/baconjs.d.ts | 0 17 files changed, 1095 insertions(+), 1 deletion(-) create mode 100644 packages/resources-provider-plugin/.gitignore create mode 100644 packages/resources-provider-plugin/.npmignore create mode 100644 packages/resources-provider-plugin/.prettierrc.json create mode 100644 packages/resources-provider-plugin/CHANGELOG.md create mode 100644 packages/resources-provider-plugin/LICENSE create mode 100644 packages/resources-provider-plugin/README.md create mode 100644 packages/resources-provider-plugin/package.json create mode 100644 packages/resources-provider-plugin/src/@types/geojson-validation.d.ts create mode 100644 packages/resources-provider-plugin/src/@types/geolib.d.ts create mode 100644 packages/resources-provider-plugin/src/index.ts create mode 100644 packages/resources-provider-plugin/src/lib/filestorage.ts create mode 100644 packages/resources-provider-plugin/src/lib/utils.ts create mode 100644 packages/resources-provider-plugin/src/types/index.ts create mode 100644 packages/resources-provider-plugin/src/types/store.ts create mode 100644 packages/resources-provider-plugin/tsconfig.json rename packages/server-api/src/{types => @types}/baconjs.d.ts (100%) diff --git a/package.json b/package.json index 2cc394f6e..d60ada531 100644 --- a/package.json +++ b/package.json @@ -73,7 +73,8 @@ "packages/server-admin-ui-dependencies", "packages/server-admin-ui", "packages/streams", - "packages/server-api" + "packages/server-api", + "packages/resources-provider-plugin" ], "dependencies": { "@signalk/n2k-signalk": "^2.0.0", diff --git a/packages/resources-provider-plugin/.gitignore b/packages/resources-provider-plugin/.gitignore new file mode 100644 index 000000000..e9eb3dedf --- /dev/null +++ b/packages/resources-provider-plugin/.gitignore @@ -0,0 +1,3 @@ +/plugin +/node_modules +package-lock.json diff --git a/packages/resources-provider-plugin/.npmignore b/packages/resources-provider-plugin/.npmignore new file mode 100644 index 000000000..2b4bb4972 --- /dev/null +++ b/packages/resources-provider-plugin/.npmignore @@ -0,0 +1,4 @@ +package-lock.json +package.json +/src +tsconfig.json diff --git a/packages/resources-provider-plugin/.prettierrc.json b/packages/resources-provider-plugin/.prettierrc.json new file mode 100644 index 000000000..8c158f2dc --- /dev/null +++ b/packages/resources-provider-plugin/.prettierrc.json @@ -0,0 +1,5 @@ +{ + "semi": false, + "singleQuote": true, + "trailingComma": "none" +} \ No newline at end of file diff --git a/packages/resources-provider-plugin/CHANGELOG.md b/packages/resources-provider-plugin/CHANGELOG.md new file mode 100644 index 000000000..87e658a12 --- /dev/null +++ b/packages/resources-provider-plugin/CHANGELOG.md @@ -0,0 +1,40 @@ +# CHANGELOG: RESOURCES-PROVIDER + +___Note: Signal K server on which this plugin is installed must implement the `ResourceProvider API`.___ + +--- + +## v1.0.0 + +Resource Provider plugin that facilitates the storage and retrieval of resources on the Signal K server filesystem. + +By default it is enabled to handle the following Signal K resource types: +- `routes` +- `waypoints` +- `notes` +- `regions` + +Each resource type can individually enabled / disabled via the Plugin Config screen of the Signal K server. + +The plugin can also be configured to handle additional `custom` resource types. + +All resource types are stored on the local filesystem of the Signal K server with each type within its own folder. + +The parent folder under which resources are stored can be configured from within the plugin config screen. The default path is `~/.signalk/resources`. +``` +.signalk + /resources + /routes + ... + /waypoints + ... + /notes + ... + /regions + ... + /my_custom_type + ... +``` + +![image](https://user-images.githubusercontent.com/38519157/150449889-5049a624-821c-4f33-ba8b-596b6b643d07.png) + diff --git a/packages/resources-provider-plugin/LICENSE b/packages/resources-provider-plugin/LICENSE new file mode 100644 index 000000000..261eeb9e9 --- /dev/null +++ b/packages/resources-provider-plugin/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/resources-provider-plugin/README.md b/packages/resources-provider-plugin/README.md new file mode 100644 index 000000000..c41becef1 --- /dev/null +++ b/packages/resources-provider-plugin/README.md @@ -0,0 +1,77 @@ +# Signal K Resources Provider Plugin: + +__Signal K server plugin that implements the Resource Provider API__. + +_Note: This plugin should ONLY be installed on a Signal K server that implements the `Resources API`!_ + +--- + +This plugin is a resource provider, facilitating the storage and retrieval of the following resource types defined by the Signal K specification: +- `resources/routes` +- `resources/waypoints` +- `resources/notes` +- `resources/regions` + +as well as providing the capability to serve custom resource types provisioned as additional paths under `/signalk/v1/api/resources`. + +- _example:_ `resources/fishingZones` + +Each path is provisioned with `GET`, `PUT`, `POST` and `DELETE` operations enabled. + +Operation of all paths is as set out in the Signal K specification. + + +--- +## Installation and Configuration: + +1. Install the plugin via the Signal K server __AppStore__ + +1. Re-start the Signal K server to load the plugin. The plugin will be active with all managed resource types enabled. + +1. `(optional)` De-select any resource types you want to disable. + +1. `(optional)` Specify any custom resource paths you require. + +1. By default resources will be stored under the path `~/.signalk/resources`. You can define an alternative path in the plugin configuration screen. The path will be created if it does not exist. _(Note: The path you enter is relative to the `~/.signalk` folder.)_ + +1. Click __Submit__ + +![image](https://user-images.githubusercontent.com/38519157/150449889-5049a624-821c-4f33-ba8b-596b6b643d07.png) + +--- + +## Data Storage: + +Resources are stored in the server's filesystem under the path entered in the configuration screen. + +A separate file is created for each resource with a name that reflects the resources `id`. + +Each resource is created within a folder allocated to that specific resource type. + +_Example:_ +``` +~/.signalk + /resources + /routes + ... + /waypoints + ... + /notes + ... + /regions + ... + /my_custom_type + ... +``` + + +--- +## Use and Operation: + +Once configured, the plugin registers itself as the resource provider for each of the enabled resource types and the Signal K server will pass all _HTTP GET, POST, PUT and DELETE_ requests to the plugin. + +--- + +_For further information about working with resources please refer to the [Signal K specification](https://signalk.org/specification) and [Signal K Server documentation](https://github.com/SignalK/signalk-server#readme)._ + + diff --git a/packages/resources-provider-plugin/package.json b/packages/resources-provider-plugin/package.json new file mode 100644 index 000000000..cb7655093 --- /dev/null +++ b/packages/resources-provider-plugin/package.json @@ -0,0 +1,47 @@ +{ + "name": "@signalk/resources-provider", + "version": "1.0.0", + "description": "Resources provider plugin for Signal K server.", + "main": "plugin/index.js", + "keywords": [ + "signalk-node-server-plugin", + "signalk-category-chart-plotters", + "signalk", + "resources", + "routes", + "waypoints", + "regions", + "notes" + ], + "repository": "https://github.com/SignalK/resources-provider", + "author": "AdrianP", + "contributors": [ + { + "name": "panaaj@hotmail.com" + } + ], + "license": "Apache-20", + "scripts": { + "build": "tsc", + "build-declaration": "tsc --declaration --allowJs false", + "watch": "npm run build -- -w", + "test": "echo \"Error: no test specified\" && exit 1", + "start": "npm run build -- -w", + "prepare": "tsc", + "format": "prettier --write src/*" + }, + "dependencies": { + "geojson-validation": "^0.2.0", + "geolib": "^3.3.3", + "ngeohash": "^0.6.3" + }, + "devDependencies": { + "@signalk/server-api": "^1.39.0", + "@types/express": "^4.17.6", + "@types/ngeohash": "^0.6.4", + "@types/node-fetch": "^2.5.6", + "prettier": "^2.5.1", + "typescript": "^4.5.4" + }, + "signalk-plugin-enabled-by-default": true +} diff --git a/packages/resources-provider-plugin/src/@types/geojson-validation.d.ts b/packages/resources-provider-plugin/src/@types/geojson-validation.d.ts new file mode 100644 index 000000000..a2e92c5c9 --- /dev/null +++ b/packages/resources-provider-plugin/src/@types/geojson-validation.d.ts @@ -0,0 +1 @@ +declare module 'geojson-validation' diff --git a/packages/resources-provider-plugin/src/@types/geolib.d.ts b/packages/resources-provider-plugin/src/@types/geolib.d.ts new file mode 100644 index 000000000..e8ee39cbb --- /dev/null +++ b/packages/resources-provider-plugin/src/@types/geolib.d.ts @@ -0,0 +1 @@ +declare module 'geolib' diff --git a/packages/resources-provider-plugin/src/index.ts b/packages/resources-provider-plugin/src/index.ts new file mode 100644 index 000000000..32b0afc81 --- /dev/null +++ b/packages/resources-provider-plugin/src/index.ts @@ -0,0 +1,284 @@ +import { + Plugin, + PluginServerApp, + ResourceProviderRegistry +} from '@signalk/server-api' +// *********************************************** +import { FileStore, getUuid } from './lib/filestorage' +import { StoreRequestParams } from './types' + +interface ResourceProviderApp + extends PluginServerApp, + ResourceProviderRegistry { + statusMessage?: () => string + error: (msg: string) => void + debug: (msg: string) => void + setPluginStatus: (pluginId: string, status?: string) => void + setPluginError: (pluginId: string, status?: string) => void + setProviderStatus: (providerId: string, status?: string) => void + setProviderError: (providerId: string, status?: string) => void + getSelfPath: (path: string) => void + savePluginOptions: (options: any, callback: () => void) => void + config: { configPath: string } +} + +const CONFIG_SCHEMA = { + properties: { + standard: { + type: 'object', + title: 'Resources (standard)', + description: + 'ENABLE / DISABLE storage provider for the following SignalK resource types.', + properties: { + routes: { + type: 'boolean', + title: 'ROUTES' + }, + waypoints: { + type: 'boolean', + title: 'WAYPOINTS' + }, + notes: { + type: 'boolean', + title: 'NOTES' + }, + regions: { + type: 'boolean', + title: 'REGIONS' + } + } + }, + custom: { + type: 'array', + title: 'Resources (custom)', + description: 'Define paths for custom resource types.', + items: { + type: 'object', + required: ['name'], + properties: { + name: { + type: 'string', + title: 'Name', + description: 'Path name to use /signalk/v1/api/resources/' + } + } + } + }, + path: { + type: 'string', + title: + 'Path to Resource data: URL or file system path (relative to home//.signalk)', + default: './resources' + } + } +} + +const CONFIG_UISCHEMA = { + standard: { + routes: { + 'ui:widget': 'checkbox', + 'ui:title': ' ', + 'ui:help': 'Signal K Route resources' + }, + waypoints: { + 'ui:widget': 'checkbox', + 'ui:title': ' ', + 'ui:help': 'Signal K Waypoint resources' + }, + notes: { + 'ui:widget': 'checkbox', + 'ui:title': ' ', + 'ui:help': 'Signal K Note resources' + }, + regions: { + 'ui:widget': 'checkbox', + 'ui:title': ' ', + 'ui:help': 'Signal K Region resources' + } + }, + path: { + 'ui:emptyValue': './resources', + 'ui:help': 'Enter URL or path relative to home//.signalk/' + } +} + +module.exports = (server: ResourceProviderApp): Plugin => { + let subscriptions: any[] = [] // stream subscriptions + + const plugin: Plugin = { + id: 'resources-provider', + name: 'Resources Provider', + schema: () => CONFIG_SCHEMA, + uiSchema: () => CONFIG_UISCHEMA, + start: (options: any, restart: any) => { + doStartup(options) + }, + stop: () => { + doShutdown() + } + } + + const db: FileStore = new FileStore(plugin.id, server.debug) + + let config: any = { + standard: { + routes: true, + waypoints: true, + notes: true, + regions: true + }, + custom: [], + path: './resources' + } + + const doStartup = (options: any) => { + try { + server.debug(`${plugin.name} starting.......`) + if (options && options.standard) { + config = options + } else { + // save defaults if no options loaded + server.savePluginOptions(config, () => { + server.debug(`Default configuration applied...`) + }) + } + server.debug(`Applied config: ${JSON.stringify(config)}`) + + // compile list of enabled resource types + let apiProviderFor: string[] = [] + Object.entries(config.standard).forEach((i) => { + if (i[1]) { + apiProviderFor.push(i[0]) + } + }) + + if (config.custom && Array.isArray(config.custom)) { + const customTypes = config.custom.map((i: any) => { + return i.name + }) + apiProviderFor = apiProviderFor.concat(customTypes) + } + + server.debug( + `** Enabled resource types: ${JSON.stringify(apiProviderFor)}` + ) + + // ** initialise resource storage + db.init({ settings: config, path: server.config.configPath }) + .then((res: { error: boolean; message: string }) => { + if (res.error) { + const msg = `*** ERROR: ${res.message} ***` + server.error(msg) + server.setPluginError(msg) + } + + server.debug( + `** ${plugin.name} started... ${!res.error ? 'OK' : 'with errors!'}` + ) + + // register as provider for enabled resource types + const result = registerProviders(apiProviderFor) + + const msg = + result.length !== 0 + ? `${result.toString()} not registered!` + : `Providing: ${apiProviderFor.toString()}` + + if (typeof server.setPluginStatus === 'function') { + server.setPluginStatus(msg) + } else { + server.setProviderStatus(msg) + } + }) + .catch((e: Error) => { + server.debug(e.message) + const msg = `Initialisation Error! See console for details.` + server.setPluginError(msg) + }) + } catch (error) { + const msg = `Started with errors!` + server.setPluginError(msg) + server.error('error: ' + error) + } + } + + const doShutdown = () => { + server.debug(`${plugin.name} stopping.......`) + server.debug('** Un-registering Update Handler(s) **') + subscriptions.forEach((b) => b()) + subscriptions = [] + const msg = 'Stopped.' + if (typeof server.setPluginStatus === 'function') { + server.setPluginStatus(msg) + } else { + server.setProviderStatus(msg) + } + } + + const getVesselPosition = () => { + const p: any = server.getSelfPath('navigation.position') + return p && p.value ? [p.value.longitude, p.value.latitude] : null + } + + const registerProviders = (resTypes: string[]): string[] => { + const failed: string[] = [] + resTypes.forEach((resType) => { + try { + server.registerResourceProvider({ + type: resType, + methods: { + listResources: (params: object): any => { + return apiGetResources(resType, params) + }, + getResource: (id: string) => { + return db.getResource(resType, getUuid(id)) + }, + setResource: (id: string, value: any) => { + return apiSetResource(resType, id, value) + }, + deleteResource: (id: string) => { + return apiSetResource(resType, id, null) + } + } + }) + } catch (error) { + failed.push(resType) + } + }) + return failed + } + + // ******* Signal K server Resource Provider interface functions ************** + + const apiGetResources = async ( + resType: string, + params?: any + ): Promise => { + if (typeof params.position === 'undefined') { + params.position = getVesselPosition() + } + server.debug(`*** apiGetResource: ${resType}, ${JSON.stringify(params)}`) + return await db.getResources(resType, params) + } + + const apiSetResource = async ( + resType: string, + id: string, + value: any + ): Promise => { + server.debug(`*** apiSetResource: ${resType}, ${id}, ${value}`) + const r: StoreRequestParams = { + type: resType, + id, + value + } + try { + await db.setResource(r) + return + } catch (error) { + throw error + } + } + + return plugin +} diff --git a/packages/resources-provider-plugin/src/lib/filestorage.ts b/packages/resources-provider-plugin/src/lib/filestorage.ts new file mode 100644 index 000000000..06a210f0f --- /dev/null +++ b/packages/resources-provider-plugin/src/lib/filestorage.ts @@ -0,0 +1,213 @@ +import { constants } from 'fs' +import { + access, + mkdir, + readdir, + readFile, + stat, + unlink, + writeFile +} from 'fs/promises' +import path from 'path' +import { IResourceStore, StoreRequestParams } from '../types' +import { passFilter, processParameters, UUID_PREFIX } from './utils' + +export const getUuid = (skIdentifier: string) => + skIdentifier.split(':').slice(-1)[0] + +// ** File Resource Store Class +export class FileStore implements IResourceStore { + savePath: string + resources: any + pkg: { id: string } + + constructor(pluginId: string, private debug: (s: any) => void) { + this.savePath = '' + this.resources = {} + this.pkg = { id: pluginId } + } + + // ** check / create path to persist resources + async init(config: any): Promise<{ error: boolean; message: string }> { + if (typeof config.settings.path === 'undefined') { + this.savePath = config.path + '/resources' + } else if (config.settings.path[0] == '/') { + this.savePath = config.settings.path + } else { + this.savePath = path.join(config.path, config.settings.path) + } + // std resources + if (config.settings.standard) { + Object.keys(config.settings.standard).forEach((i: any) => { + this.resources[i] = { path: path.join(this.savePath, `/${i}`) } + }) + } + // other resources + const enabledResTypes: any = {} + Object.assign(enabledResTypes, config.settings.standard) + if (config.settings.custom && Array.isArray(config.settings.custom)) { + config.settings.custom.forEach((i: any) => { + this.resources[i.name] = { + path: path.join(this.savePath, `/${i.name}`) + } + enabledResTypes[i.name] = true + }) + } + + try { + await this.checkPath(this.savePath) + } catch (error) { + throw new Error(`Unable to create ${this.savePath}!`) + } + return await this.createSavePaths(enabledResTypes) + } + + // ** create save paths for resource types + async createSavePaths( + resTypes: any + ): Promise<{ error: boolean; message: string }> { + this.debug('** Initialising resource storage **') + const result = { error: false, message: `` } + Object.keys(this.resources).forEach(async (t: string) => { + if (resTypes[t]) { + try { + await access(this.resources[t].path, constants.W_OK | constants.R_OK) + this.debug(`${this.resources[t].path} - OK....`) + } catch (error) { + this.debug(`${this.resources[t].path} NOT available...`) + this.debug(`Creating ${this.resources[t].path} ...`) + try { + await mkdir(this.resources[t].path, { recursive: true }) + this.debug(`Created ${this.resources[t].path} - OK....`) + } catch (error) { + result.error = true + result.message += `ERROR creating ${this.resources[t].path} folder\r\n ` + } + } + } + }) + return result + } + + async getResource(type: string, itemUuid: string): Promise { + try { + const result = JSON.parse( + await readFile(path.join(this.resources[type].path, itemUuid), 'utf8') + ) + const stats = await stat(path.join(this.resources[type].path, itemUuid)) + result.timestamp = stats.mtime + result.$source = this.pkg.id + return result + } catch (e: any) { + if (e.code === 'ENOENT') { + return Promise.reject(`No such resource ${type} ${itemUuid}`) + } + console.error(e) + return Promise.reject(`Error retrieving resource ${type} ${itemUuid}`) + } + } + + // ** return persisted resources from storage + async getResources( + type: string, + params: any + ): Promise<{ [key: string]: any }> { + let result: any = {} + // ** parse supplied params + params = processParameters(params) + try { + // return matching resources + const rt = this.resources[type] + const files = await readdir(rt.path) + // check resource count + const fcount = + params.limit && files.length > params.limit + ? params.limit + : files.length + let count = 0 + for (const f in files) { + if (++count > fcount) { + break + } + try { + const res = JSON.parse( + await readFile(path.join(rt.path, files[f]), 'utf8') + ) + // ** apply param filters ** + if (passFilter(res, type, params)) { + const uuid = UUID_PREFIX + files[f] + result[uuid] = res + const stats: any = stat(path.join(rt.path, files[f])) + result[uuid].timestamp = stats.mtime + result[uuid].$source = this.pkg.id + } + } catch (err) { + console.error(err) + throw new Error(`Invalid file contents: ${files[f]}`) + } + } + return result + } catch (error) { + console.error(error) + throw new Error( + `Error retreiving resources from ${this.savePath}. Ensure plugin is active or restart plugin!` + ) + } + } + + // ** save / delete (r.value==null) resource file + async setResource(r: StoreRequestParams): Promise { + const fname = getUuid(r.id) + const p = path.join(this.resources[r.type].path, fname) + + if (r.value === null) { + // ** delete file ** + try { + await unlink(p) + this.debug(`** DELETED: ${r.type} entry ${fname} **`) + return + } catch (error) { + console.error('Error deleting resource!') + ;(error as Error).message = 'Error deleting resource!' + throw error + } + } else { + // ** add / update file + try { + await writeFile(p, JSON.stringify(r.value)) + this.debug(`** ${r.type} written to ${fname} **`) + return + } catch (error) { + console.error('Error updating resource!') + throw error + } + } + } + + // ** check path exists / create it if it doesn't ** + async checkPath(path: string = this.savePath): Promise { + if (!path) { + throw new Error(`Path not supplied!`) + } + try { + await access( + // check path exists + path, + constants.W_OK | constants.R_OK + ) + this.debug(`${path} - OK...`) + return true + } catch (error) { + // if not then create it + this.debug(`${path} does NOT exist...`) + this.debug(`Creating ${path} ...`) + try { + await mkdir(path, { recursive: true }) + this.debug(`Created ${path} - OK...`) + return true + } catch (error) { + throw new Error(`Unable to create ${path}!`) + } + } + } +} diff --git a/packages/resources-provider-plugin/src/lib/utils.ts b/packages/resources-provider-plugin/src/lib/utils.ts new file mode 100644 index 000000000..ca88b28b2 --- /dev/null +++ b/packages/resources-provider-plugin/src/lib/utils.ts @@ -0,0 +1,168 @@ +// ** utility library functions ** + +import { + computeDestinationPoint, + getCenterOfBounds, + isPointInPolygon +} from 'geolib' +import ngeohash from 'ngeohash' + +export const UUID_PREFIX = 'urn:mrn:signalk:uuid:' + +// ** check geometry is in bounds +export const inBounds = ( + val: any, + type: string, + polygon: number[] +): boolean => { + let ok = false + switch (type) { + case 'notes': + case 'waypoints': + if (val?.feature?.geometry?.coordinates) { + ok = isPointInPolygon(val?.feature?.geometry?.coordinates, polygon) + } + if (val.position) { + ok = isPointInPolygon(val.position, polygon) + } + if (val.geohash) { + const bar = ngeohash.decode_bbox(val.geohash) + const bounds = toPolygon([bar[1], bar[0], bar[3], bar[2]]) + const center = getCenterOfBounds(bounds) + ok = isPointInPolygon(center, polygon) + } + break + case 'routes': + if (val.feature.geometry.coordinates) { + val.feature.geometry.coordinates.forEach((pt: any) => { + ok = ok || isPointInPolygon(pt, polygon) + }) + } + break + case 'regions': + if ( + val.feature.geometry.coordinates && + val.feature.geometry.coordinates.length > 0 + ) { + if (val.feature.geometry.type == 'Polygon') { + val.feature.geometry.coordinates.forEach((ls: any) => { + ls.forEach((pt: any) => { + ok = ok || isPointInPolygon(pt, polygon) + }) + }) + } else if (val.feature.geometry.type == 'MultiPolygon') { + val.feature.geometry.coordinates.forEach((polygon: any) => { + polygon.forEach((ls: any) => { + ls.forEach((pt: any) => { + ok = ok || isPointInPolygon(pt, polygon) + }) + }) + }) + } + } + break + } + return ok +} + +/** Apply filters to Resource entry + * returns: true if entry should be included in results **/ +export const passFilter = (res: any, type: string, params: any) => { + let ok = true + if (params.href) { + // ** check is attached to another resource + // console.log(`filter related href: ${params.href}`); + if (typeof res.href === 'undefined') { + ok = ok && false + } else { + // deconstruct resource href value + const ha = res.href.split('/') + const hType: string = + ha.length === 1 + ? 'regions' + : ha.length > 2 + ? ha[ha.length - 2] + : 'regions' + const hId = ha.length === 1 ? ha[0] : ha[ha.length - 1] + + // deconstruct param.href value + const pa = params.href.split('/') + const pType: string = + pa.length === 1 + ? 'regions' + : pa.length > 2 + ? pa[pa.length - 2] + : 'regions' + const pId = pa.length === 1 ? pa[0] : pa[pa.length - 1] + + ok = ok && hType === pType && hId === pId + } + } + if (params.group) { + // ** check is attached to group + // console.error(`check group: ${params.group}`); + if (typeof res.group === 'undefined') { + ok = ok && false + } else { + ok = ok && res.group == params.group + } + } + if (params.geobounds) { + // ** check is within bounds + ok = ok && inBounds(res, type, params.geobounds) + } + return ok +} + +// ** process query parameters +export const processParameters = (params: any) => { + if (typeof params.limit !== 'undefined') { + if (isNaN(params.limit)) { + throw new Error( + `max record count specified is not a number! (${params.limit})` + ) + } else { + params.limit = parseInt(params.limit) + } + } + + if (typeof params.bbox !== 'undefined') { + // ** generate geobounds polygon from bbox + params.geobounds = toPolygon(params.bbox) + if (params.geobounds.length !== 5) { + params.geobounds = null + throw new Error( + `Bounding box contains invalid coordinate value (${params.bbox})` + ) + } + } else if (typeof params.distance !== 'undefined' && params.position) { + if (isNaN(params.distance)) { + throw new Error( + `Distance specified is not a number! (${params.distance})` + ) + } + const sw = computeDestinationPoint(params.position, params.distance, 225) + const ne = computeDestinationPoint(params.position, params.distance, 45) + params.geobounds = toPolygon( + [sw.longitude, sw.latitude, ne.longitude, ne.latitude] + ) + } + return params +} + +// ** convert bbox string to array of points (polygon) ** +export const toPolygon = (bbox: number[]) => { + const polygon = [] + if (bbox.length == 4) { + polygon.push([bbox[0], bbox[1]]) + polygon.push([bbox[0], bbox[3]]) + polygon.push([bbox[2], bbox[3]]) + polygon.push([bbox[2], bbox[1]]) + polygon.push([bbox[0], bbox[1]]) + } else { + console.error( + `*** Error: Bounding box contains invalid coordinate value (${bbox}) ***` + ) + } + return polygon +} diff --git a/packages/resources-provider-plugin/src/types/index.ts b/packages/resources-provider-plugin/src/types/index.ts new file mode 100644 index 000000000..16c863321 --- /dev/null +++ b/packages/resources-provider-plugin/src/types/index.ts @@ -0,0 +1 @@ +export * from './store' diff --git a/packages/resources-provider-plugin/src/types/store.ts b/packages/resources-provider-plugin/src/types/store.ts new file mode 100644 index 000000000..f8f8dad27 --- /dev/null +++ b/packages/resources-provider-plugin/src/types/store.ts @@ -0,0 +1,18 @@ +// ** Resource Store Interface +export interface IResourceStore { + savePath: string + resources: any + init: (basePath: string) => Promise + getResources: ( + type: string, + item: any, + params: { [key: string]: any } + ) => Promise<{ [key: string]: any }> + setResource: (r: StoreRequestParams) => Promise +} + +export interface StoreRequestParams { + id: string + type: string + value: any +} diff --git a/packages/resources-provider-plugin/tsconfig.json b/packages/resources-provider-plugin/tsconfig.json new file mode 100644 index 000000000..e347b85dc --- /dev/null +++ b/packages/resources-provider-plugin/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "target": "es6", + "module": "commonjs", + "outDir": "./plugin", + "esModuleInterop": true, + "strict": true, + "allowJs": true, + "allowSyntheticDefaultImports": true, + "resolveJsonModule": true + }, + "include": ["./src/**/*"], + "exclude": ["node_modules"], + "typedocOptions": { + "mode": "modules", + "out": "tsdocs", + "exclude": ["test", "node_modules"], + "theme": "default", + "ignoreCompilerErrors": true, + "excludePrivate": true, + "excludeNotExported": true, + "target": "ES5", + "moduleResolution": "node", + "preserveConstEnums": true, + "stripInternal": true, + "suppressExcessPropertyErrors": true, + "suppressImplicitAnyIndexErrors": true, + "module": "commonjs" + } +} \ No newline at end of file diff --git a/packages/server-api/src/types/baconjs.d.ts b/packages/server-api/src/@types/baconjs.d.ts similarity index 100% rename from packages/server-api/src/types/baconjs.d.ts rename to packages/server-api/src/@types/baconjs.d.ts From 866f54484ed2f3deb845a7b696820a3bdbdd15a7 Mon Sep 17 00:00:00 2001 From: Teppo Kurki Date: Sun, 27 Mar 2022 22:54:49 +0300 Subject: [PATCH 03/63] chore: add ts project references --- .gitignore | 2 ++ package.json | 2 +- packages/resources-provider-plugin/tsconfig.json | 4 +++- packages/server-api/tsconfig.json | 4 ++-- tsconfig.json | 14 ++++++++++---- 5 files changed, 18 insertions(+), 8 deletions(-) diff --git a/.gitignore b/.gitignore index 3a86273d9..d3b82fb23 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ node_modules !test/plugin-test-config/node_modules/ +*.tsbuildinfo + lib/ .DS_Store diff --git a/package.json b/package.json index d60ada531..cc4a22656 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "An implementation of a [Signal K](http://signalk.org) server for boats.", "main": "index.js", "scripts": { - "build": "tsc", + "build": "tsc --build", "build:all": "npm run build:workspaces && npm run build", "build:workspaces": "npm run build --workspaces --if-present", "build-declaration": "tsc --declaration --allowJs false", diff --git a/packages/resources-provider-plugin/tsconfig.json b/packages/resources-provider-plugin/tsconfig.json index e347b85dc..319da4211 100644 --- a/packages/resources-provider-plugin/tsconfig.json +++ b/packages/resources-provider-plugin/tsconfig.json @@ -7,7 +7,9 @@ "strict": true, "allowJs": true, "allowSyntheticDefaultImports": true, - "resolveJsonModule": true + "resolveJsonModule": true, + "composite": true, + "rootDir": "src" }, "include": ["./src/**/*"], "exclude": ["node_modules"], diff --git a/packages/server-api/tsconfig.json b/packages/server-api/tsconfig.json index b97b4b9dd..5023d43d6 100644 --- a/packages/server-api/tsconfig.json +++ b/packages/server-api/tsconfig.json @@ -15,8 +15,8 @@ // "sourceMap": true, /* Generates corresponding '.map' file. */ // "outFile": "./", /* Concatenate and emit output to single file. */ "outDir": "./dist", /* Redirect output structure to the directory. */ - // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ - // "composite": true, /* Enable project compilation */ + "rootDir": "src", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ + "composite": true, /* Enable project compilation */ // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ // "removeComments": true, /* Do not emit comments to output. */ // "noEmit": true, /* Do not emit outputs. */ diff --git a/tsconfig.json b/tsconfig.json index f47786721..75f6a09af 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,9 +10,7 @@ "resolveJsonModule": true, "rootDir": "./src" }, - "include": [ - "./src/**/*" - ], + "include": ["./src/**/*"], "exclude": ["node_modules"], "typedocOptions": { "mode": "modules", @@ -29,5 +27,13 @@ "suppressExcessPropertyErrors": true, "suppressImplicitAnyIndexErrors": true, "module": "commonjs" - } + }, + "references": [ + { + "path": "./packages/server-api" + }, + { + "path": "./packages/resources-provider-plugin" + } + ] } From a8c9735bb70181ce36e253196fc0c6f792cd772d Mon Sep 17 00:00:00 2001 From: panaaj <38519157+panaaj@users.noreply.github.com> Date: Wed, 23 Mar 2022 14:28:01 +1030 Subject: [PATCH 04/63] tweak chart definition --- src/api/resources/openApi.json | 127 ++++++++++++++++++--------------- 1 file changed, 68 insertions(+), 59 deletions(-) diff --git a/src/api/resources/openApi.json b/src/api/resources/openApi.json index 5dce2fb98..8f8b90478 100644 --- a/src/api/resources/openApi.json +++ b/src/api/resources/openApi.json @@ -579,7 +579,7 @@ } ] }, - "MBTilesLayersModel": { + "TileLayerExtModel": { "description": "When format='pbf' Lists the layers that appear in the vector tiles and the names and types of the attributes of features that appear in those layers.", "type": "object", "required": [ @@ -610,35 +610,8 @@ } } }, - "MBTilesExtModel": { - "description": "Extends TileJSON schema with MBTiles extensions.", - "type": "object", - "required": [ - "format" - ], - "properties": { - "format": { - "type": "string", - "description": "The file format of the tile data.", - "enum": ["pbf", "jpg", "png", "webp"], - "example": "png" - }, - "type": { - "type": "string", - "description": "layer type", - "enum": ["overlay", "baselayer"] - }, - "vector_layers": { - "type": "array", - "description": "When format='pbf' Lists the layers that appear in the vector tiles and the names and types of the attributes of features that appear in those layers.", - "items": { - "$ref": "#/components/schemas/MBTilesLayersModel" - } - } - } - }, - "TileJSONModel": { - "description": "TileJSON schema model", + "TileLayerSource": { + "description": "Tile layer metadata model", "type": "object", "required": [ "sourceType", @@ -668,18 +641,6 @@ }, "example": "http://localhost:3000/signalk/v2/api/resources/charts/islands/{z}/{x}/{y}.png" }, - "name": { - "type": "string", - "description": "tileset name.", - "example": "Indonesia", - "default": null - }, - "description": { - "type": "string", - "description": "A text description of the tileset.", - "example": "Indonesian coastline", - "default": null - }, "version": { "type": "string", "description": "A semver.org style version number defining the version of the chart content.", @@ -743,26 +704,33 @@ -41.27498133450632, 8 ] - } - } - }, - "TileSet": { - "allOf": [ - { - "$ref": "#/components/schemas/TileJSONModel" }, - { - "$ref": "#/components/schemas/MBTilesExtModel" + "format": { + "type": "string", + "description": "The file format of the tile data.", + "enum": ["pbf", "jpg", "png", "webp"], + "example": "png" + }, + "type": { + "type": "string", + "description": "layer type", + "enum": ["overlay", "baselayer"] + }, + "vector_layers": { + "type": "array", + "description": "When format='pbf' Lists the layers that appear in the vector tiles and the names and types of the attributes of features that appear in those layers.", + "items": { + "$ref": "#/components/schemas/TileLayerExtModel" + } } - ] + } }, - "WMSModel": { - "description": "WMS and WMTS attributes", + "WmsSourceModel": { + "description": "WMS / WMTS source model", "type": "object", "required": [ "sourceType", - "url", - "layers" + "url" ], "properties": { "sourceType": { @@ -793,6 +761,30 @@ } } }, + "TileJsonSource": { + "description": "TileJSON source model", + "type": "object", + "required": [ + "sourceType", + "url" + ], + "properties": { + "sourceType": { + "type": "string", + "description": "Source type of chart data.", + "enum": [ + "tilejson" + ], + "default": "tilejson", + "example": "tilejson" + }, + "url": { + "type": "string", + "description": "URL to TileJSON file", + "example": "http://mapserver.org/mychart.json" + } + } + }, "ChartBaseModel": { "description": "Signal K Chart resource", "type": "object", @@ -802,12 +794,26 @@ "properties": { "identifier": { "type": "string", - "description": "chart identifier / number", + "description": "Chart identifier / number", "example": "NZ615" }, + "name": { + "type": "string", + "description": "Chart name.", + "example": "Tasman Bay", + "default": null + }, + "description": { + "type": "string", + "description": "A text description of the chart.", + "example": "Tasman Bay coastline", + "default": null + }, "scale": { "type": "number", "description": "chart scale", + "minimum": 1, + "default": 250000, "example": 250000 } } @@ -820,10 +826,13 @@ ], "oneOf": [ { - "$ref": "#/components/schemas/TileSet" + "$ref": "#/components/schemas/TileLayerSource" + }, + { + "$ref": "#/components/schemas/TileJsonSource" }, { - "$ref": "#/components/schemas/WMSModel" + "$ref": "#/components/schemas/WmsSourceModel" } ] }, From 609e5c6701a982dcb108d376efe3e0b024941cd7 Mon Sep 17 00:00:00 2001 From: Teppo Kurki Date: Sun, 27 Mar 2022 23:04:17 +0300 Subject: [PATCH 05/63] chore: remove resources plugin prepare Now that the plugin is in the same repo ts compilation should happen from the root of the project (server), as project references are not available in leaf projects. --- packages/resources-provider-plugin/package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/resources-provider-plugin/package.json b/packages/resources-provider-plugin/package.json index cb7655093..224b9b374 100644 --- a/packages/resources-provider-plugin/package.json +++ b/packages/resources-provider-plugin/package.json @@ -27,7 +27,6 @@ "watch": "npm run build -- -w", "test": "echo \"Error: no test specified\" && exit 1", "start": "npm run build -- -w", - "prepare": "tsc", "format": "prettier --write src/*" }, "dependencies": { From 52e2f073b8ae089f2dc22217a18a7e89aacc13dc Mon Sep 17 00:00:00 2001 From: Teppo Kurki Date: Sun, 27 Mar 2022 23:08:38 +0300 Subject: [PATCH 06/63] fix compile error build --all fails on this. --- packages/resources-provider-plugin/src/lib/filestorage.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/resources-provider-plugin/src/lib/filestorage.ts b/packages/resources-provider-plugin/src/lib/filestorage.ts index 06a210f0f..2558afb77 100644 --- a/packages/resources-provider-plugin/src/lib/filestorage.ts +++ b/packages/resources-provider-plugin/src/lib/filestorage.ts @@ -98,7 +98,7 @@ export class FileStore implements IResourceStore { result.timestamp = stats.mtime result.$source = this.pkg.id return result - } catch (e: any) { + } catch (e) { if (e.code === 'ENOENT') { return Promise.reject(`No such resource ${type} ${itemUuid}`) } From d3335324aa292ec726a21848ddccb4df2c0b99e6 Mon Sep 17 00:00:00 2001 From: Teppo Kurki Date: Sun, 27 Mar 2022 23:27:22 +0300 Subject: [PATCH 07/63] style: lint --- src/api/course/index.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/api/course/index.ts b/src/api/course/index.ts index d7078657b..61c7c166e 100644 --- a/src/api/course/index.ts +++ b/src/api/course/index.ts @@ -649,7 +649,6 @@ export class CourseApi { } private buildDeltaMsg(): any { - const values: Array<{ path: string; value: any }> = [] const navPath = 'navigation.course' @@ -705,7 +704,7 @@ export class CourseApi { return { updates: [ { - values: values + values } ] } From 0f5bd56a70537730cacaa70a06aff0acd637b3b8 Mon Sep 17 00:00:00 2001 From: panaaj <38519157+panaaj@users.noreply.github.com> Date: Mon, 4 Apr 2022 10:57:59 +0930 Subject: [PATCH 08/63] update resources-provider label as built-in --- packages/resources-provider-plugin/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/resources-provider-plugin/src/index.ts b/packages/resources-provider-plugin/src/index.ts index 32b0afc81..088b3776f 100644 --- a/packages/resources-provider-plugin/src/index.ts +++ b/packages/resources-provider-plugin/src/index.ts @@ -107,7 +107,7 @@ module.exports = (server: ResourceProviderApp): Plugin => { const plugin: Plugin = { id: 'resources-provider', - name: 'Resources Provider', + name: 'Resources Provider (built-in)', schema: () => CONFIG_SCHEMA, uiSchema: () => CONFIG_UISCHEMA, start: (options: any, restart: any) => { From f917f92204d4d2e1329ad61d0d0af444b47cb551 Mon Sep 17 00:00:00 2001 From: panaaj <38519157+panaaj@users.noreply.github.com> Date: Mon, 4 Apr 2022 11:35:23 +0930 Subject: [PATCH 09/63] fix resources plugin compile error --- packages/resources-provider-plugin/src/lib/filestorage.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/resources-provider-plugin/src/lib/filestorage.ts b/packages/resources-provider-plugin/src/lib/filestorage.ts index 2558afb77..9ddfa904b 100644 --- a/packages/resources-provider-plugin/src/lib/filestorage.ts +++ b/packages/resources-provider-plugin/src/lib/filestorage.ts @@ -99,7 +99,7 @@ export class FileStore implements IResourceStore { result.$source = this.pkg.id return result } catch (e) { - if (e.code === 'ENOENT') { + if ((e as any).code === 'ENOENT') { return Promise.reject(`No such resource ${type} ${itemUuid}`) } console.error(e) From da2e3fcce08d1768cda9de37dbeb6c3d06b9ec48 Mon Sep 17 00:00:00 2001 From: Teppo Kurki Date: Mon, 4 Apr 2022 22:44:01 +0300 Subject: [PATCH 10/63] chore: build docker image for this branch --- .github/workflows/build-docker.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/build-docker.yml b/.github/workflows/build-docker.yml index 8cb533718..399ea8ab0 100644 --- a/.github/workflows/build-docker.yml +++ b/.github/workflows/build-docker.yml @@ -5,6 +5,7 @@ on: branches: - master - "build-docker" + - "resources_course_api" tags: - '*' - '!v*' From 537ff9c8398e9fb195dcf9616a6f2c9b70628fb5 Mon Sep 17 00:00:00 2001 From: panaaj <38519157+panaaj@users.noreply.github.com> Date: Wed, 6 Apr 2022 10:44:57 +0930 Subject: [PATCH 11/63] chore: update link to resourses-provider-plugin --- RESOURCE_PROVIDER_PLUGINS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/RESOURCE_PROVIDER_PLUGINS.md b/RESOURCE_PROVIDER_PLUGINS.md index cb21f5a87..9969a8564 100644 --- a/RESOURCE_PROVIDER_PLUGINS.md +++ b/RESOURCE_PROVIDER_PLUGINS.md @@ -2,7 +2,7 @@ _This document should be read in conjunction with [SERVERPLUGINS.md](./SERVERPLUGINS.md) as it contains additional information regarding the development of plugins that facilitate the storage and retrieval of resource data._ -To see an example of a resource provider plugin see [resources-provider-plugin](https://github.com/SignalK/resources-provider-plugin/) +To see an example of a resource provider plugin see [resources-provider-plugin](https://github.com/SignalK/signalk-server/tree/master/packages/resources-provider-plugin) --- From 5b3697916f0a543ae7c431e600ecb676eeb4a0ef Mon Sep 17 00:00:00 2001 From: panaaj <38519157+panaaj@users.noreply.github.com> Date: Wed, 6 Apr 2022 11:34:13 +0930 Subject: [PATCH 12/63] chore: tweak chart definition --- src/api/resources/openApi.json | 3098 ++++++++++++++++---------------- 1 file changed, 1545 insertions(+), 1553 deletions(-) diff --git a/src/api/resources/openApi.json b/src/api/resources/openApi.json index 8f8b90478..00335b647 100644 --- a/src/api/resources/openApi.json +++ b/src/api/resources/openApi.json @@ -1,191 +1,213 @@ { - "openapi": "3.0.0", - "info": { - "version": "2.0.0", - "title": "Signal K Resources API", - "termsOfService": "http://signalk.org/terms/", - "license": { - "name": "Apache 2.0", - "url": "http://www.apache.org/licenses/LICENSE-2.0.html" - } + "openapi": "3.0.0", + "info": { + "version": "2.0.0", + "title": "Signal K Resources API", + "termsOfService": "http://signalk.org/terms/", + "license": { + "name": "Apache 2.0", + "url": "http://www.apache.org/licenses/LICENSE-2.0.html" + } + }, + "externalDocs": { + "url": "http://signalk.org/specification/", + "description": "Signal K specification." + }, + "servers": [ + { + "description": "Signal K Server", + "url": "http://localhost:3000/signalk/v2/api" + } + ], + "tags": [ + { + "name": "resources", + "description": "Signal K resources" }, - "externalDocs": { - "url": "http://signalk.org/specification/", - "description": "Signal K specification." + { + "name": "routes", + "description": "Route operations" }, - "servers": [ - { - "description": "Signal K Server", - "url": "http://localhost:3000/signalk/v2/api" - } - ], - "tags": [ - { - "name": "resources", - "description": "Signal K resources" - }, - { - "name": "routes", - "description": "Route operations" + { + "name": "waypoints", + "description": "Waypoint operations" + }, + { + "name": "regions", + "description": "Region operations" + }, + { + "name": "notes", + "description": "Note operations" + }, + { + "name": "charts", + "description": "Chart operations" + } + ], + "components": { + "schemas": { + "Coordinate": { + "type": "array", + "maxItems": 3, + "minItems": 2, + "items": { + "type": "number" + } }, - { - "name": "waypoints", - "description": "Waypoint operations" + "LineStringCoordinates": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Coordinate" + } }, - { - "name": "regions", - "description": "Region operations" + "PolygonCoordinates": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LineStringCoordinates" + } }, - { - "name": "notes", - "description": "Note operations" + "MultiPolygonCoordinates": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PolygonCoordinates" + } }, - { - "name": "charts", - "description": "Chart operations" - } - ], - "components": { - "schemas": { - "Coordinate": { - "type": "array", - "maxItems": 3, - "minItems": 2, - "items": { - "type": "number" - } + "Point": { + "type": "object", + "description": "GeoJSon Point geometry", + "externalDocs": { + "url": "http://geojson.org/geojson-spec.html#id2" }, - "LineStringCoordinates": { - "type": "array", - "items": { + "required": ["type"], + "properties": { + "type": { + "type": "string", + "enum": [ + "Point" + ] + }, + "coordinates": { "$ref": "#/components/schemas/Coordinate" } + } + }, + "LineString": { + "type": "object", + "description": "GeoJSon LineString geometry", + "externalDocs": { + "url": "http://geojson.org/geojson-spec.html#id3" }, - "PolygonCoordinates": { - "type": "array", - "items": { + "required": ["type"], + "properties": { + "type": { + "type": "string", + "enum": [ + "LineString" + ] + }, + "coordinates": { "$ref": "#/components/schemas/LineStringCoordinates" } + } + }, + "Polygon": { + "type": "object", + "description": "GeoJSon Polygon geometry", + "externalDocs": { + "url": "http://geojson.org/geojson-spec.html#id4" }, - "MultiPolygonCoordinates": { - "type": "array", - "items": { + "required": ["type"], + "properties": { + "type": { + "type": "string", + "enum": [ + "Polygon" + ] + }, + "coordinates": { "$ref": "#/components/schemas/PolygonCoordinates" } + } + }, + "MultiPolygon": { + "type": "object", + "description": "GeoJSon MultiPolygon geometry", + "externalDocs": { + "url": "http://geojson.org/geojson-spec.html#id6" }, - "Point": { - "type": "object", - "description": "GeoJSon Point geometry", - "externalDocs": { - "url": "http://geojson.org/geojson-spec.html#id2" + "required": ["type"], + "properties": { + "type": { + "type": "string", + "enum": [ + "MultiPolygon" + ] }, - "required": ["type"], - "properties": { - "type": { - "type": "string", - "enum": [ - "Point" - ] - }, - "coordinates": { - "$ref": "#/components/schemas/Coordinate" - } + "coordinates": { + "$ref": "#/components/schemas/MultiPolygonCoordinates" } - }, - "LineString": { - "type": "object", - "description": "GeoJSon LineString geometry", - "externalDocs": { - "url": "http://geojson.org/geojson-spec.html#id3" + } + }, + "SignalKUuid": { + "type": "string", + "pattern": "urn:mrn:signalk:uuid:[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-4[0-9A-Fa-f]{3}-[89ABab][0-9A-Fa-f]{3}-[0-9A-Fa-f]{12}$", + "example": "urn:mrn:signalk:uuid:ac3a3b2d-07e8-4f25-92bc-98e7c92f7f1a" + }, + "SignalKHref": { + "type": "string", + "pattern": "^\/resources\/(\\w*)\/urn:mrn:signalk:uuid:[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-4[0-9A-Fa-f]{3}-[89ABab][0-9A-Fa-f]{3}-[0-9A-Fa-f]{12}$" + }, + "SignalKPosition": { + "type": "object", + "required": [ + "latitude", + "longitude" + ], + "properties": { + "latitude": { + "type": "number", + "format": "float" }, - "required": ["type"], - "properties": { - "type": { - "type": "string", - "enum": [ - "LineString" - ] - }, - "coordinates": { - "$ref": "#/components/schemas/LineStringCoordinates" - } - } - }, - "Polygon": { - "type": "object", - "description": "GeoJSon Polygon geometry", - "externalDocs": { - "url": "http://geojson.org/geojson-spec.html#id4" + "longitude": { + "type": "number", + "format": "float" }, - "required": ["type"], - "properties": { - "type": { - "type": "string", - "enum": [ - "Polygon" - ] - }, - "coordinates": { - "$ref": "#/components/schemas/PolygonCoordinates" - } + "altitude": { + "type": "number", + "format": "float" } + } + }, + "SignalKPositionArray": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SignalKPosition" }, - "MultiPolygon": { - "type": "object", - "description": "GeoJSon MultiPolygon geometry", - "externalDocs": { - "url": "http://geojson.org/geojson-spec.html#id6" + "description": "Array of points.", + "example": [ + { + "latitude": 65.4567, + "longitude": 3.3452 }, - "required": ["type"], - "properties": { - "type": { - "type": "string", - "enum": [ - "MultiPolygon" - ] - }, - "coordinates": { - "$ref": "#/components/schemas/MultiPolygonCoordinates" - } - } - }, - "SignalKUuid": { - "type": "string", - "pattern": "urn:mrn:signalk:uuid:[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-4[0-9A-Fa-f]{3}-[89ABab][0-9A-Fa-f]{3}-[0-9A-Fa-f]{12}$", - "example": "urn:mrn:signalk:uuid:ac3a3b2d-07e8-4f25-92bc-98e7c92f7f1a" - }, - "SignalKHref": { - "type": "string", - "pattern": "^\/resources\/(\\w*)\/urn:mrn:signalk:uuid:[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-4[0-9A-Fa-f]{3}-[89ABab][0-9A-Fa-f]{3}-[0-9A-Fa-f]{12}$" - }, - "SignalKPosition": { - "type": "object", - "required": [ - "latitude", - "longitude" - ], - "properties": { - "latitude": { - "type": "number", - "format": "float" - }, - "longitude": { - "type": "number", - "format": "float" - }, - "altitude": { - "type": "number", - "format": "float" - } + { + "latitude": 65.5567, + "longitude": 3.3352 + }, + { + "latitude": 65.5777, + "longitude": 3.3261 } + ] + }, + "SignalKPositionPolygon": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SignalKPositionArray" }, - "SignalKPositionArray": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SignalKPosition" - }, - "description": "Array of points.", - "example": [ + "description": "Array of SignalKPositionArray.", + "example": [ + [ { "latitude": 65.4567, "longitude": 3.3452 @@ -198,15 +220,31 @@ "latitude": 65.5777, "longitude": 3.3261 } + ], + [ + { + "latitude": 64.4567, + "longitude": 4.3452 + }, + { + "latitude": 64.5567, + "longitude": 4.3352 + }, + { + "latitude": 64.5777, + "longitude": 4.3261 + } ] + ] + }, + "SignalKPositionMultiPolygon": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SignalKPositionPolygon" }, - "SignalKPositionPolygon": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SignalKPositionArray" - }, - "description": "Array of SignalKPositionArray.", - "example": [ + "description": "Array of SignalKPositionPolygon.", + "example": [ + [ [ { "latitude": 65.4567, @@ -235,927 +273,880 @@ "longitude": 4.3261 } ] - ] - }, - "SignalKPositionMultiPolygon": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SignalKPositionPolygon" - }, - "description": "Array of SignalKPositionPolygon.", - "example": [ + ], + [ [ - [ - { - "latitude": 65.4567, - "longitude": 3.3452 - }, - { - "latitude": 65.5567, - "longitude": 3.3352 - }, - { - "latitude": 65.5777, - "longitude": 3.3261 - } - ], - [ - { - "latitude": 64.4567, - "longitude": 4.3452 - }, - { - "latitude": 64.5567, - "longitude": 4.3352 - }, - { - "latitude": 64.5777, - "longitude": 4.3261 - } - ] + { + "latitude": 75.4567, + "longitude": 3.3452 + }, + { + "latitude": 75.5567, + "longitude": 3.3352 + }, + { + "latitude": 75.5777, + "longitude": 3.3261 + } ], [ - [ - { - "latitude": 75.4567, - "longitude": 3.3452 - }, - { - "latitude": 75.5567, - "longitude": 3.3352 - }, + { + "latitude": 74.4567, + "longitude": 4.3452 + }, + { + "latitude": 74.5567, + "longitude": 4.3352 + }, + { + "latitude": 74.5777, + "longitude": 4.3261 + } + ] + ] + ] + }, + "HrefAttribute": { + "type": "object", + "required": ["href"], + "properties": { + "href": { + "description": "Reference to a related resource. A pointer to the resource UUID.", + "example": "/resources/waypoints/urn:mrn:signalk:uuid:ac3a3b2d-07e8-4f25-92bc-98e7c92f7f1a", + "allOf": [ { - "latitude": 75.5777, - "longitude": 3.3261 + "$ref": "#/components/schemas/SignalKHref" } - ], - [ - { - "latitude": 74.4567, - "longitude": 4.3452 - }, - { - "latitude": 74.5567, - "longitude": 4.3352 - }, + ] + } + } + }, + "PositionAttribute": { + "type": "object", + "required": ["position"], + "properties": { + "position": { + "description": "Resource location.", + "example": { + "latitude": 65.4567, + "longitude": 3.3452 + }, + "allOf": [ { - "latitude": 74.5777, - "longitude": 4.3261 + "$ref": "#/components/schemas/SignalKPosition" } - ] ] - ] - }, - "HrefAttribute": { - "type": "object", - "required": ["href"], - "properties": { - "href": { - "description": "Reference to a related resource. A pointer to the resource UUID.", - "example": "/resources/waypoints/urn:mrn:signalk:uuid:ac3a3b2d-07e8-4f25-92bc-98e7c92f7f1a", - "allOf": [ - { - "$ref": "#/components/schemas/SignalKHref" - } - ] - } } - }, - "PositionAttribute": { - "type": "object", - "required": ["position"], - "properties": { - "position": { - "description": "Resource location.", - "example": { - "latitude": 65.4567, - "longitude": 3.3452 + } + }, + "Route": { + "type": "object", + "description": "Signal K Route resource", + "required": [ + "feature" + ], + "properties": { + "name": { + "type": "string", + "description": "Route's common name" + }, + "description": { + "type": "string", + "description": "A description of the route" + }, + "distance": { + "description": "Total distance from start to end", + "type": "number" + }, + "feature": { + "type": "object", + "title": "Feature", + "description": "A GeoJSON feature object which describes a route", + "properties": { + "geometry": { + "$ref": "#/components/schemas/LineString" }, - "allOf": [ - { - "$ref": "#/components/schemas/SignalKPosition" - } - ] - } - } - }, - "Route": { - "type": "object", - "description": "Signal K Route resource", - "required": [ - "feature" - ], - "properties": { - "name": { - "type": "string", - "description": "Route's common name" - }, - "description": { - "type": "string", - "description": "A description of the route" - }, - "distance": { - "description": "Total distance from start to end", - "type": "number" - }, - "feature": { - "type": "object", - "title": "Feature", - "description": "A GeoJSON feature object which describes a route", "properties": { - "geometry": { - "$ref": "#/components/schemas/LineString" - }, - "properties": { - "description": "Additional feature properties", - "type": "object", - "additionalProperties": true - } + "description": "Additional feature properties", + "type": "object", + "additionalProperties": true } } } - }, - "RoutePostModel": { - "description": "Route API resource request payload", - "type": "object", - "required": [ - "points" - ], + } + }, + "RoutePostModel": { + "description": "Route API resource request payload", + "type": "object", + "required": [ + "points" + ], + "properties": { + "name": { + "type": "string", + "description": "Title of route" + }, + "description": { + "type": "string", + "description": "Text describing route" + }, + "points": { + "$ref": "#/components/schemas/SignalKPositionArray" + }, "properties": { - "name": { - "type": "string", - "description": "Title of route" - }, - "description": { - "type": "string", - "description": "Text describing route" - }, - "points": { - "$ref": "#/components/schemas/SignalKPositionArray" - }, - "properties": { - "type": "object", - "additionalProperties": true - } + "type": "object", + "additionalProperties": true } - }, - "Waypoint": { - "description": "Signal K Waypoint resource", - "type": "object", - "required": [ - "feature" - ], - "properties": { - "name": { - "type": "string", - "description": "Waypoint's common name" - }, - "description": { - "type": "string", - "description": "A description of the waypoint" - }, - "feature": { - "type": "object", - "title": "Feature", - "description": "A Geo JSON feature object which describes a waypoint", + } + }, + "Waypoint": { + "description": "Signal K Waypoint resource", + "type": "object", + "required": [ + "feature" + ], + "properties": { + "name": { + "type": "string", + "description": "Waypoint's common name" + }, + "description": { + "type": "string", + "description": "A description of the waypoint" + }, + "feature": { + "type": "object", + "title": "Feature", + "description": "A Geo JSON feature object which describes a waypoint", + "properties": { + "geometry": { + "$ref": "#/components/schemas/Point" + }, "properties": { - "geometry": { - "$ref": "#/components/schemas/Point" - }, - "properties": { - "description": "Additional feature properties", - "type": "object", - "additionalProperties": true - } + "description": "Additional feature properties", + "type": "object", + "additionalProperties": true } } } - }, - "WaypointPostModel": { - "description": "Waypoint API resource request payload", - "type": "object", - "required": [ - "position" - ], + } + }, + "WaypointPostModel": { + "description": "Waypoint API resource request payload", + "type": "object", + "required": [ + "position" + ], + "properties": { + "name": { + "type": "string", + "description": "Name of waypoint" + }, + "description": { + "type": "string", + "description": "Waypoint description" + }, + "position": { + "allOf": [ + { + "$ref": "#/components/schemas/SignalKPosition" + } + ], + "description": "Waypoint position." + }, "properties": { - "name": { - "type": "string", - "description": "Name of waypoint" - }, - "description": { - "type": "string", - "description": "Waypoint description" - }, - "position": { - "allOf": [ - { - "$ref": "#/components/schemas/SignalKPosition" - } - ], - "description": "Waypoint position." - }, + "type": "object", + "additionalProperties": true + } + } + }, + "Region": { + "description": "Signal K Region resource", + "type": "object", + "required": [ + "feature" + ], + "properties": { + "name": { + "type": "string", + "description": "Region's common name" + }, + "description": { + "type": "string", + "description": "A description of the region" + }, + "feature": { + "type": "object", + "title": "Feature", + "description": "A Geo JSON feature object which describes the regions boundary", "properties": { - "type": "object", - "additionalProperties": true + "geometry": { + "oneOf": [ + { + "$ref": "#/components/schemas/Polygon" + }, + { + "$ref": "#/components/schemas/MultiPolygon" + } + ] + }, + "properties": { + "description": "Additional feature properties", + "type": "object", + "additionalProperties": true + } } } - }, - "Region": { - "description": "Signal K Region resource", - "type": "object", - "required": [ - "feature" - ], - "properties": { - "name": { - "type": "string", - "description": "Region's common name" - }, - "description": { - "type": "string", - "description": "A description of the region" - }, - "feature": { - "type": "object", - "title": "Feature", - "description": "A Geo JSON feature object which describes the regions boundary", - "properties": { - "geometry": { - "oneOf": [ - { - "$ref": "#/components/schemas/Polygon" - }, - { - "$ref": "#/components/schemas/MultiPolygon" - } - ] - }, - "properties": { - "description": "Additional feature properties", - "type": "object", - "additionalProperties": true - } + } + }, + "RegionPostModel": { + "description": "Region API resource request payload", + "type": "object", + "required": [ + "points" + ], + "properties": { + "name": { + "type": "string", + "description": "Title of region" + }, + "description": { + "type": "string", + "description": "Text describing region" + }, + "points": { + "oneOf": [ + { + "$ref": "#/components/schemas/SignalKPositionArray" + }, + { + "$ref": "#/components/schemas/SignalKPositionPolygon" + }, + { + "$ref": "#/components/schemas/SignalKPositionMultiPolygon" } - } - } - }, - "RegionPostModel": { - "description": "Region API resource request payload", - "type": "object", - "required": [ - "points" - ], - "properties": { - "name": { - "type": "string", - "description": "Title of region" - }, - "description": { - "type": "string", - "description": "Text describing region" - }, - "points": { - "oneOf": [ - { - "$ref": "#/components/schemas/SignalKPositionArray" - }, - { - "$ref": "#/components/schemas/SignalKPositionPolygon" - }, - { - "$ref": "#/components/schemas/SignalKPositionMultiPolygon" - } - ] - }, - "properties": { - "type": "object", - "additionalProperties": true - } - } - }, - "NoteBaseModel": { - "description": "Signal K Note resource", - "type": "object", + ] + }, "properties": { - "title": { - "type": "string", - "description": "Title of note" - }, - "description": { - "type": "string", - "description": "Text describing note" - }, - "mimeType": { - "type": "string", - "description": "MIME type of the note" - }, - "url": { - "type": "string", - "description": "Location of the note" - }, - "properties": { - "description": "Additional user defined note properties", - "type": "object", - "additionalProperties": true, - "example": { - "group": "My Note group", - "author": "M Jones" - } - } + "type": "object", + "additionalProperties": true } - }, - "Note": { - "allOf": [ - { - "$ref": "#/components/schemas/NoteBaseModel" - } - ], - "oneOf": [ - { - "$ref": "#/components/schemas/HrefAttribute" - }, - { - "$ref": "#/components/schemas/PositionAttribute" - } - ] - }, - "TileLayerExtModel": { - "description": "When format='pbf' Lists the layers that appear in the vector tiles and the names and types of the attributes of features that appear in those layers.", - "type": "object", - "required": [ - "id", - "fields" - ], + } + }, + "NoteBaseModel": { + "description": "Signal K Note resource", + "type": "object", + "properties": { + "title": { + "type": "string", + "description": "Title of note" + }, + "description": { + "type": "string", + "description": "Text describing note" + }, + "mimeType": { + "type": "string", + "description": "MIME type of the note" + }, + "url": { + "type": "string", + "description": "Location of the note" + }, "properties": { - "id": { - "type": "string", - "description": "Layer id." - }, - "fields": { - "type": "object", - "description": "A JSON object whose keys and values are the names and types of attributes available in this layer. ", - "additionalProperties": true - }, - "description": { - "type": "string", - "description": "Layer description." - }, - "minzoom": { - "type": "string", - "description": "The lowest zoom level whose tiles this layer appears in." - }, - "maxzoom": { - "type": "string", - "description": "he highest zoom level whose tiles this layer appears in." + "description": "Additional user defined note properties", + "type": "object", + "additionalProperties": true, + "example": { + "group": "My Note group", + "author": "M Jones" } } - }, - "TileLayerSource": { - "description": "Tile layer metadata model", - "type": "object", - "required": [ - "sourceType", - "tilejson", - "tiles" - ], - "properties": { - "sourceType": { - "type": "string", - "description": "Source type of chart data.", - "enum": [ - "tilejson" - ], - "default": "tilejson", - "example": "tilejson" - }, - "tilejson": { - "type": "string", - "description": "A semver.org style version number describing the version of the TileJSON spec.", - "example": "2.2.0" - }, - "tiles": { - "type": "array", - "description": "An array of chart tile endpoints {z}, {x} and {y}. The array MUST contain at least one endpoint.", - "items": { - "type": "string" - }, - "example": "http://localhost:3000/signalk/v2/api/resources/charts/islands/{z}/{x}/{y}.png" - }, - "version": { - "type": "string", - "description": "A semver.org style version number defining the version of the chart content.", - "example": "1.0.0", - "default": "1.0.0" - }, - "attribution": { - "type": "string", - "description": "Contains an attribution to be displayed when the map is shown.", - "example": "OSM contributors", - "default": null - }, - "scheme": { - "type": "string", - "description": "Influences the y direction of the tile coordinates.", - "enum": ["xyz", "tms"], - "example": "xyz", - "default": "xyz" - }, - "bounds": { - "description": "The maximum extent of available chart tiles in the format left, bottom, right, top.", - "type": "array", - "items": { - "type": "number" - }, - "minItems": 4, - "maxItems": 4, - "example": [ - 172.7499244562935, - -41.27498133450632, - 173.9166560895481, - -40.70659187633642 - ] - }, - "minzoom": { - "type": "number", - "description": "An integer specifying the minimum zoom level.", - "example": 19, - "default": 0, - "minimum": 0, - "maximum": 30 - }, - "maxzoom": { - "type": "number", - "description": "An integer specifying the maximum zoom level. MUST be >= minzoom.", - "example": 27, - "default": 0, - "minimum": 0, - "maximum": 30 - }, - "center": { - "description": "Center of chart expressed as [longitude, latitude, zoom].", - "type": "array", - "items": { - "type": "number" - }, - "minItems": 3, - "maxItems": 3, - "example": [ - 172.7499244562935, - -41.27498133450632, - 8 - ] - }, - "format": { - "type": "string", - "description": "The file format of the tile data.", - "enum": ["pbf", "jpg", "png", "webp"], - "example": "png" - }, - "type": { - "type": "string", - "description": "layer type", - "enum": ["overlay", "baselayer"] - }, - "vector_layers": { - "type": "array", - "description": "When format='pbf' Lists the layers that appear in the vector tiles and the names and types of the attributes of features that appear in those layers.", - "items": { - "$ref": "#/components/schemas/TileLayerExtModel" - } - } + } + }, + "Note": { + "allOf": [ + { + "$ref": "#/components/schemas/NoteBaseModel" } - }, - "WmsSourceModel": { - "description": "WMS / WMTS source model", - "type": "object", - "required": [ - "sourceType", - "url" - ], - "properties": { - "sourceType": { - "type": "string", - "description": "Source type of chart data.", - "enum": [ - "wmts", - "wms" - ], - "default": "wmts", - "example": "wms" - }, - "url": { - "type": "string", - "description": "URL to WMS / WMTS service", - "example": "http://mapserver.org/wmts" - }, - "layers": { - "type": "array", - "items": { - "type": "string" - }, - "description": "List of chart layers to display.", - "example": [ - "Restricted Areas", - "Fishing Zones" - ] - } + ], + "oneOf": [ + { + "$ref": "#/components/schemas/HrefAttribute" + }, + { + "$ref": "#/components/schemas/PositionAttribute" } - }, - "TileJsonSource": { - "description": "TileJSON source model", - "type": "object", - "required": [ - "sourceType", - "url" - ], - "properties": { - "sourceType": { - "type": "string", - "description": "Source type of chart data.", - "enum": [ - "tilejson" - ], - "default": "tilejson", - "example": "tilejson" - }, - "url": { - "type": "string", - "description": "URL to TileJSON file", - "example": "http://mapserver.org/mychart.json" - } + ] + }, + "TileLayerExtModel": { + "description": "When format='pbf' Lists the layers that appear in the vector tiles and the names and types of the attributes of features that appear in those layers.", + "type": "object", + "required": [ + "id", + "fields" + ], + "properties": { + "id": { + "type": "string", + "description": "Layer id." + }, + "fields": { + "type": "object", + "description": "A JSON object whose keys and values are the names and types of attributes available in this layer. ", + "additionalProperties": true + }, + "description": { + "type": "string", + "description": "Layer description." + }, + "minzoom": { + "type": "string", + "description": "The lowest zoom level whose tiles this layer appears in." + }, + "maxzoom": { + "type": "string", + "description": "he highest zoom level whose tiles this layer appears in." } - }, - "ChartBaseModel": { - "description": "Signal K Chart resource", - "type": "object", - "required": [ - "identifier" - ], - "properties": { - "identifier": { - "type": "string", - "description": "Chart identifier / number", - "example": "NZ615" + } + }, + "TileLayerSource": { + "description": "Tile layer metadata model", + "type": "object", + "required": [ + "sourceType", + "tiles" + ], + "properties": { + "sourceType": { + "type": "string", + "description": "Source type of chart data.", + "enum": [ + "tilelayer" + ], + "default": "tilelayer", + "example": "tilelayer" + }, + "tiles": { + "type": "array", + "description": "An array of chart tile endpoints {z}, {x} and {y}. The array MUST contain at least one endpoint.", + "items": { + "type": "string" }, - "name": { - "type": "string", - "description": "Chart name.", - "example": "Tasman Bay", - "default": null + "example": "http://localhost:3000/signalk/v2/api/resources/charts/islands/{z}/{x}/{y}.png" + }, + "bounds": { + "description": "The maximum extent of available chart tiles in the format left, bottom, right, top.", + "type": "array", + "items": { + "type": "number" }, - "description": { - "type": "string", - "description": "A text description of the chart.", - "example": "Tasman Bay coastline", - "default": null + "minItems": 4, + "maxItems": 4, + "example": [ + 172.7499244562935, + -41.27498133450632, + 173.9166560895481, + -40.70659187633642 + ] + }, + "minzoom": { + "type": "number", + "description": "An integer specifying the minimum zoom level.", + "example": 19, + "default": 0, + "minimum": 0, + "maximum": 30 + }, + "maxzoom": { + "type": "number", + "description": "An integer specifying the maximum zoom level. MUST be >= minzoom.", + "example": 27, + "default": 0, + "minimum": 0, + "maximum": 30 + }, + "format": { + "type": "string", + "description": "The file format of the tile data.", + "enum": ["pbf", "jpg", "png", "webp"], + "example": "png" + }, + "scheme": { + "type": "string", + "description": "Influences the y direction of the tile coordinates.", + "enum": ["xyz", "tms"], + "example": "xyz", + "default": "xyz" + }, + "tilejson": { + "type": "string", + "description": "A semver.org style version number describing the version of the TileJSON spec.", + "example": "2.2.0" + }, + "version": { + "type": "string", + "description": "A semver.org style version number defining the version of the chart content.", + "example": "1.0.0", + "default": "1.0.0" + }, + "attribution": { + "type": "string", + "description": "Contains an attribution to be displayed when the map is shown.", + "example": "OSM contributors", + "default": null + }, + "type": { + "type": "string", + "description": "layer type", + "enum": ["overlay", "baselayer"] + }, + "center": { + "description": "Center of chart expressed as [longitude, latitude, zoom].", + "type": "array", + "items": { + "type": "number" }, - "scale": { - "type": "number", - "description": "chart scale", - "minimum": 1, - "default": 250000, - "example": 250000 + "minItems": 3, + "maxItems": 3, + "example": [ + 172.7499244562935, + -41.27498133450632, + 8 + ] + }, + "vector_layers": { + "type": "array", + "description": "When format='pbf' Lists the layers that appear in the vector tiles and the names and types of the attributes of features that appear in those layers.", + "items": { + "$ref": "#/components/schemas/TileLayerExtModel" } } - }, - "Chart": { - "allOf": [ - { - "$ref": "#/components/schemas/ChartBaseModel" - } - ], - "oneOf": [ - { - "$ref": "#/components/schemas/TileLayerSource" - }, - { - "$ref": "#/components/schemas/TileJsonSource" - }, - { - "$ref": "#/components/schemas/WmsSourceModel" - } - ] - }, - "BaseResponseModel": { - "description": "base model for resource entry response", - "type": "object", - "required": [ - "timestamp", - "$source" - ], - "properties": { - "timestamp": { + } + }, + "WmsSourceModel": { + "description": "WMS / WMTS source model", + "type": "object", + "required": [ + "sourceType", + "url" + ], + "properties": { + "sourceType": { + "type": "string", + "description": "Source type of chart data.", + "enum": [ + "wmts", + "wms" + ], + "default": "wmts", + "example": "wms" + }, + "url": { + "type": "string", + "description": "URL to WMS / WMTS service", + "example": "http://mapserver.org/wmts" + }, + "layers": { + "type": "array", + "items": { "type": "string" }, - "$source": { - "type": "string" - } + "description": "List of chart layers to display.", + "example": [ + "Restricted Areas", + "Fishing Zones" + ] + } + } + }, + "TileJsonSource": { + "description": "TileJSON source model", + "type": "object", + "required": [ + "sourceType", + "url" + ], + "properties": { + "sourceType": { + "type": "string", + "description": "Source type of chart data.", + "enum": [ + "tilejson" + ], + "default": "tilejson", + "example": "tilejson" + }, + "url": { + "type": "string", + "description": "URL to TileJSON file", + "example": "http://mapserver.org/mychart.json" + } + } + }, + "Chart": { + "description": "Signal K Chart resource", + "type": "object", + "required": [ + "identifier" + ], + "properties": { + "identifier": { + "type": "string", + "description": "Chart identifier / number", + "example": "NZ615" + }, + "name": { + "type": "string", + "description": "Chart name.", + "example": "Tasman Bay", + "default": null + }, + "description": { + "type": "string", + "description": "A text description of the chart.", + "example": "Tasman Bay coastline", + "default": null + }, + "scale": { + "type": "number", + "description": "chart scale", + "minimum": 1, + "default": 250000, + "example": 250000 } }, - "RouteResponseModel": { - "allOf": [ - { - "$ref": "#/components/schemas/BaseResponseModel" - }, - { - "$ref": "#/components/schemas/Route" - } - ] - }, - "WaypointResponseModel": { - "allOf": [ - { - "$ref": "#/components/schemas/BaseResponseModel" - }, - { - "$ref": "#/components/schemas/Waypoint" - } - ] - }, - "NoteResponseModel": { - "allOf": [ - { - "$ref": "#/components/schemas/BaseResponseModel" - }, - { - "$ref": "#/components/schemas/NoteBaseModel" - } - ], - "oneOf": [ - { - "$ref": "#/components/schemas/HrefAttribute" - }, - { - "$ref": "#/components/schemas/PositionAttribute" - } - ] - }, - "RegionResponseModel": { - "allOf": [ - { - "$ref": "#/components/schemas/BaseResponseModel" - }, - { - "$ref": "#/components/schemas/Region" - } - ] - }, - "ChartResponseModel": { - "allOf": [ - { - "$ref": "#/components/schemas/BaseResponseModel" - }, - { - "$ref": "#/components/schemas/Chart" + "oneOf": [ + { + "$ref": "#/components/schemas/TileLayerSource" + }, + { + "$ref": "#/components/schemas/TileJsonSource" + }, + { + "$ref": "#/components/schemas/WmsSourceModel" + } + ] + }, + "BaseResponseModel": { + "description": "base model for resource entry response", + "type": "object", + "required": [ + "timestamp", + "$source" + ], + "properties": { + "timestamp": { + "type": "string" + }, + "$source": { + "type": "string" + } + } + }, + "RouteResponseModel": { + "allOf": [ + { + "$ref": "#/components/schemas/BaseResponseModel" + }, + { + "$ref": "#/components/schemas/Route" + } + ] + }, + "WaypointResponseModel": { + "allOf": [ + { + "$ref": "#/components/schemas/BaseResponseModel" + }, + { + "$ref": "#/components/schemas/Waypoint" + } + ] + }, + "NoteResponseModel": { + "allOf": [ + { + "$ref": "#/components/schemas/BaseResponseModel" + }, + { + "$ref": "#/components/schemas/NoteBaseModel" + } + ], + "oneOf": [ + { + "$ref": "#/components/schemas/HrefAttribute" + }, + { + "$ref": "#/components/schemas/PositionAttribute" + } + ] + }, + "RegionResponseModel": { + "allOf": [ + { + "$ref": "#/components/schemas/BaseResponseModel" + }, + { + "$ref": "#/components/schemas/Region" + } + ] + }, + "ChartResponseModel": { + "allOf": [ + { + "$ref": "#/components/schemas/BaseResponseModel" + }, + { + "$ref": "#/components/schemas/Chart" + } + ] + } + }, + "responses": { + "200ActionResponse": { + "description": "PUT, DELETE OK response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "state": { + "type": "string", + "enum": [ + "COMPLETED" + ] + }, + "statusCode": { + "type": "number", + "enum": [ + 200 + ] + }, + "id": { + "$ref": "#/components/schemas/SignalKUuid" + } + }, + "required": [ + "id", + "statusCode", + "state" + ] } - ] + } } }, - "responses": { - "200ActionResponse": { - "description": "PUT, DELETE OK response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "state": { - "type": "string", - "enum": [ - "COMPLETED" - ] - }, - "statusCode": { - "type": "number", - "enum": [ - 200 - ] - }, - "id": { - "$ref": "#/components/schemas/SignalKUuid" - } + "201ActionResponse": { + "description": "POST OK response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "state": { + "type": "string", + "enum": [ + "COMPLETED" + ] }, - "required": [ - "id", - "statusCode", - "state" - ] - } - } - } - }, - "201ActionResponse": { - "description": "POST OK response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "state": { - "type": "string", - "enum": [ - "COMPLETED" - ] - }, - "statusCode": { - "type": "number", - "enum": [ - 201 - ] - }, - "id": { - "$ref": "#/components/schemas/SignalKUuid" - } + "statusCode": { + "type": "number", + "enum": [ + 201 + ] }, - "required": [ - "id", - "statusCode", - "state" - ] - } + "id": { + "$ref": "#/components/schemas/SignalKUuid" + } + }, + "required": [ + "id", + "statusCode", + "state" + ] } } - }, - "ErrorResponse": { - "description": "Failed operation", - "content": { - "application/json": { - "schema": { - "type": "object", - "description": "Request error response", - "properties": { - "state": { - "type": "string", - "enum": [ - "FAILED" - ] - }, - "statusCode": { - "type": "number", - "enum": [ - 404 - ] - }, - "message": { - "type": "string" - } + } + }, + "ErrorResponse": { + "description": "Failed operation", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Request error response", + "properties": { + "state": { + "type": "string", + "enum": [ + "FAILED" + ] }, - "required": [ - "state", - "statusCode", - "message" - ] - } - } - } - }, - "RouteResponse": { - "description": "Route record response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RouteResponseModel" - } + "statusCode": { + "type": "number", + "enum": [ + 404 + ] + }, + "message": { + "type": "string" + } + }, + "required": [ + "state", + "statusCode", + "message" + ] } } - }, - "WaypointResponse": { - "description": "Waypoint record response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/WaypointResponseModel" - } + } + }, + "RouteResponse": { + "description": "Route record response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RouteResponseModel" } } - }, - "NoteResponse": { - "description": "Note record response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NoteResponseModel" - } + } + }, + "WaypointResponse": { + "description": "Waypoint record response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WaypointResponseModel" } } - }, - "RegionResponse": { - "description": "Region record response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RegionResponseModel" - } + } + }, + "NoteResponse": { + "description": "Note record response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NoteResponseModel" } } - }, - "ChartResponse": { - "description": "Chart record response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ChartResponseModel" - } + } + }, + "RegionResponse": { + "description": "Region record response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RegionResponseModel" } } } }, - "parameters": { - "LimitParam": { - "in": "query", - "name": "limit", - "description": "Maximum number of records to return", - "schema": { - "type": "integer", - "format": "int32", - "minimum": 1, - "example": 100 - } - }, - "DistanceParam": { - "in": "query", - "name": "distance", - "description": "Limit results to resources that fall within a square area, centered around the vessel's position (or position parameter value if supplied), the edges of which are the sepecified distance in meters from the vessel.", - "schema": { - "type": "integer", - "format": "int32", - "minimum": 100, - "example": 2000 - } - }, - "BoundingBoxParam": { - "in": "query", - "name": "bbox", - "description": "Limit results to resources that fall within the bounded area defined as lower left and upper right longitude, latatitude coordinates [lon1, lat1, lon2, lat2]", - "style": "form", - "explode": false, - "schema": { - "type": "array", - "minItems": 4, - "maxItems": 4, - "items": { - "type": "number", - "format": "float", - "minimum": -180, - "maximum": 180 - }, - "example": [ - 135.5, - -25.2, - 138.1, - -28 - ] - } - }, - "PositionParam": { - "in": "query", - "name": "position", - "description": "Location, in format [longitude, latitude], from where the distance parameter is applied.", - "style": "form", - "explode": false, - "schema": { - "type": "array", - "minItems": 2, - "maxItems": 2, - "items": { - "type": "number", - "format": "float", - "minimum": -180, - "maximum": 180 - }, - "example": [ - 135.5, - -25.2 - ] + "ChartResponse": { + "description": "Chart record response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChartResponseModel" + } } } } }, - "paths": { - "/resources": { - "get": { - "tags": [ - "resources" - ], - "summary": "Retrieve list of available resource types", - "responses": { - "default": { - "description": "List of avaialble resource types identified by name", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": { - "required": [ - "$source" - ], - "properties": { - "description": { - "type": "string" - }, - "$source": { - "type": "string" - } + "parameters": { + "LimitParam": { + "in": "query", + "name": "limit", + "description": "Maximum number of records to return", + "schema": { + "type": "integer", + "format": "int32", + "minimum": 1, + "example": 100 + } + }, + "DistanceParam": { + "in": "query", + "name": "distance", + "description": "Limit results to resources that fall within a square area, centered around the vessel's position (or position parameter value if supplied), the edges of which are the sepecified distance in meters from the vessel.", + "schema": { + "type": "integer", + "format": "int32", + "minimum": 100, + "example": 2000 + } + }, + "BoundingBoxParam": { + "in": "query", + "name": "bbox", + "description": "Limit results to resources that fall within the bounded area defined as lower left and upper right longitude, latatitude coordinates [lon1, lat1, lon2, lat2]", + "style": "form", + "explode": false, + "schema": { + "type": "array", + "minItems": 4, + "maxItems": 4, + "items": { + "type": "number", + "format": "float", + "minimum": -180, + "maximum": 180 + }, + "example": [ + 135.5, + -25.2, + 138.1, + -28 + ] + } + }, + "PositionParam": { + "in": "query", + "name": "position", + "description": "Location, in format [longitude, latitude], from where the distance parameter is applied.", + "style": "form", + "explode": false, + "schema": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "items": { + "type": "number", + "format": "float", + "minimum": -180, + "maximum": 180 + }, + "example": [ + 135.5, + -25.2 + ] + } + } + } + }, + "paths": { + "/resources": { + "get": { + "tags": [ + "resources" + ], + "summary": "Retrieve list of available resource types", + "responses": { + "default": { + "description": "List of avaialble resource types identified by name", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "required": [ + "$source" + ], + "properties": { + "description": { + "type": "string" + }, + "$source": { + "type": "string" } } } @@ -1164,649 +1155,650 @@ } } } - }, - "/resources/routes": { - "get": { - "tags": [ - "routes" - ], - "summary": "Retrieve route resources", - "parameters": [ - { - "$ref": "#/components/parameters/LimitParam" - }, - { - "$ref": "#/components/parameters/DistanceParam" - }, - { - "$ref": "#/components/parameters/BoundingBoxParam" - }, - { - "$ref": "#/components/parameters/PositionParam" - } - ], - "responses": { - "default": { - "description": "List of route resources identified by their UUID", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": { - "allOf": [ - { - "$ref": "#/components/schemas/RouteResponseModel" - } - ] - } - } - } - } - } + } + }, + "/resources/routes": { + "get": { + "tags": [ + "routes" + ], + "summary": "Retrieve route resources", + "parameters": [ + { + "$ref": "#/components/parameters/LimitParam" + }, + { + "$ref": "#/components/parameters/DistanceParam" + }, + { + "$ref": "#/components/parameters/BoundingBoxParam" + }, + { + "$ref": "#/components/parameters/PositionParam" } - }, - "post": { - "tags": [ - "routes" - ], - "summary": "New Route", - "requestBody": { - "description": "API request payload", - "required": true, + ], + "responses": { + "default": { + "description": "List of route resources identified by their UUID", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RoutePostModel" + "type": "object", + "additionalProperties": { + "allOf": [ + { + "$ref": "#/components/schemas/RouteResponseModel" + } + ] + } } } } - }, - "responses": { - "201": { - "$ref": "#/components/responses/201ActionResponse" - }, - "default": { - "$ref": "#/components/responses/ErrorResponse" - } } } }, - "/resources/routes/{id}": { - "parameters": [ - { - "name": "id", - "in": "path", - "description": "route id", - "required": true, - "schema": { - "$ref": "#/components/schemas/SignalKUuid" - } - } + "post": { + "tags": [ + "routes" ], - "get": { - "tags": [ - "routes" - ], - "summary": "Retrieve route with supplied id", - "responses": { - "200": { - "$ref": "#/components/responses/RouteResponse" - }, - "default": { - "$ref": "#/components/responses/ErrorResponse" + "summary": "New Route", + "requestBody": { + "description": "API request payload", + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RoutePostModel" + } } } }, - "put": { - "tags": [ - "routes" - ], - "summary": "Add / update a new Route with supplied id", - "requestBody": { - "description": "Route resource entry", - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Route" - } - } - } + "responses": { + "201": { + "$ref": "#/components/responses/201ActionResponse" }, - "responses": { - "200": { - "$ref": "#/components/responses/200ActionResponse" - }, - "default": { - "$ref": "#/components/responses/ErrorResponse" - } + "default": { + "$ref": "#/components/responses/ErrorResponse" } - }, - "delete": { - "tags": [ - "routes" - ], - "summary": "Remove Route with supplied id", - "responses": { - "200": { - "$ref": "#/components/responses/200ActionResponse" - }, - "default": { - "$ref": "#/components/responses/ErrorResponse" - } + } + } + }, + "/resources/routes/{id}": { + "parameters": [ + { + "name": "id", + "in": "path", + "description": "route id", + "required": true, + "schema": { + "$ref": "#/components/schemas/SignalKUuid" + } + } + ], + "get": { + "tags": [ + "routes" + ], + "summary": "Retrieve route with supplied id", + "responses": { + "200": { + "$ref": "#/components/responses/RouteResponse" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" } } }, - "/resources/waypoints": { - "get": { - "tags": [ - "waypoints" - ], - "summary": "Retrieve waypoint resources", - "parameters": [ - { - "$ref": "#/components/parameters/LimitParam" - }, - { - "$ref": "#/components/parameters/DistanceParam" - }, - { - "$ref": "#/components/parameters/BoundingBoxParam" - }, - { - "$ref": "#/components/parameters/PositionParam" - } - ], - "responses": { - "default": { - "description": "List of waypoint resources identified by their UUID", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": { - "allOf": [ - { - "$ref": "#/components/schemas/WaypointResponseModel" - } - ] - } - } - } + "put": { + "tags": [ + "routes" + ], + "summary": "Add / update a new Route with supplied id", + "requestBody": { + "description": "Route resource entry", + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Route" } } } }, - "post": { - "tags": [ - "waypoints" - ], - "summary": "New Waypoint", - "requestBody": { - "description": "API request payload", - "required": true, + "responses": { + "200": { + "$ref": "#/components/responses/200ActionResponse" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + }, + "delete": { + "tags": [ + "routes" + ], + "summary": "Remove Route with supplied id", + "responses": { + "200": { + "$ref": "#/components/responses/200ActionResponse" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + }, + "/resources/waypoints": { + "get": { + "tags": [ + "waypoints" + ], + "summary": "Retrieve waypoint resources", + "parameters": [ + { + "$ref": "#/components/parameters/LimitParam" + }, + { + "$ref": "#/components/parameters/DistanceParam" + }, + { + "$ref": "#/components/parameters/BoundingBoxParam" + }, + { + "$ref": "#/components/parameters/PositionParam" + } + ], + "responses": { + "default": { + "description": "List of waypoint resources identified by their UUID", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WaypointPostModel" + "type": "object", + "additionalProperties": { + "allOf": [ + { + "$ref": "#/components/schemas/WaypointResponseModel" + } + ] + } } } } - }, - "responses": { - "201": { - "$ref": "#/components/responses/201ActionResponse" - }, - "default": { - "$ref": "#/components/responses/ErrorResponse" - } } } }, - "/resources/waypoints/{id}": { - "parameters": [ - { - "name": "id", - "in": "path", - "description": "waypoint id", - "required": true, - "schema": { - "$ref": "#/components/schemas/SignalKUuid" - } - } + "post": { + "tags": [ + "waypoints" ], - "get": { - "tags": [ - "waypoints" - ], - "summary": "Retrieve waypoint with supplied id", - "responses": { - "200": { - "$ref": "#/components/responses/WaypointResponse" - }, - "default": { - "$ref": "#/components/responses/ErrorResponse" + "summary": "New Waypoint", + "requestBody": { + "description": "API request payload", + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WaypointPostModel" + } } } }, - "put": { - "tags": [ - "waypoints" - ], - "summary": "Add / update a new Waypoint with supplied id", - "requestBody": { - "description": "Waypoint resource entry", - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Waypoint" - } - } - } + "responses": { + "201": { + "$ref": "#/components/responses/201ActionResponse" }, - "responses": { - "200": { - "$ref": "#/components/responses/200ActionResponse" - }, - "default": { - "$ref": "#/components/responses/ErrorResponse" - } + "default": { + "$ref": "#/components/responses/ErrorResponse" } - }, - "delete": { - "tags": [ - "waypoints" - ], - "summary": "Remove Waypoint with supplied id", - "responses": { - "200": { - "$ref": "#/components/responses/200ActionResponse" - }, - "default": { - "$ref": "#/components/responses/ErrorResponse" - } + } + } + }, + "/resources/waypoints/{id}": { + "parameters": [ + { + "name": "id", + "in": "path", + "description": "waypoint id", + "required": true, + "schema": { + "$ref": "#/components/schemas/SignalKUuid" + } + } + ], + "get": { + "tags": [ + "waypoints" + ], + "summary": "Retrieve waypoint with supplied id", + "responses": { + "200": { + "$ref": "#/components/responses/WaypointResponse" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" } } }, - "/resources/regions": { - "get": { - "tags": [ - "regions" - ], - "summary": "Retrieve region resources", - "parameters": [ - { - "$ref": "#/components/parameters/LimitParam" - }, - { - "$ref": "#/components/parameters/DistanceParam" - }, - { - "$ref": "#/components/parameters/BoundingBoxParam" - }, - { - "$ref": "#/components/parameters/PositionParam" - } - ], - "responses": { - "default": { - "description": "List of region resources identified by their UUID", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": { - "allOf": [ - { - "$ref": "#/components/schemas/RegionResponseModel" - } - ] - } - } - } + "put": { + "tags": [ + "waypoints" + ], + "summary": "Add / update a new Waypoint with supplied id", + "requestBody": { + "description": "Waypoint resource entry", + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Waypoint" } } } }, - "post": { - "tags": [ - "regions" - ], - "summary": "New Region", - "requestBody": { - "description": "API request payload", - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RegionPostModel" - } - } - } + "responses": { + "200": { + "$ref": "#/components/responses/200ActionResponse" }, - "responses": { - "201": { - "$ref": "#/components/responses/201ActionResponse" - }, - "default": { - "$ref": "#/components/responses/ErrorResponse" - } + "default": { + "$ref": "#/components/responses/ErrorResponse" } } }, - "/resources/regions/{id}": { + "delete": { + "tags": [ + "waypoints" + ], + "summary": "Remove Waypoint with supplied id", + "responses": { + "200": { + "$ref": "#/components/responses/200ActionResponse" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + }, + "/resources/regions": { + "get": { + "tags": [ + "regions" + ], + "summary": "Retrieve region resources", "parameters": [ { - "name": "id", - "in": "path", - "description": "region id", - "required": true, - "schema": { - "$ref": "#/components/schemas/SignalKUuid" - } + "$ref": "#/components/parameters/LimitParam" + }, + { + "$ref": "#/components/parameters/DistanceParam" + }, + { + "$ref": "#/components/parameters/BoundingBoxParam" + }, + { + "$ref": "#/components/parameters/PositionParam" } ], - "get": { - "tags": [ - "regions" - ], - "summary": "Retrieve region with supplied id", - "responses": { - "200": { - "$ref": "#/components/responses/RegionResponse" - }, - "default": { - "$ref": "#/components/responses/ErrorResponse" - } - } - }, - "put": { - "tags": [ - "regions" - ], - "summary": "Add / update a new Region with supplied id", - "requestBody": { - "description": "Region resource entry", - "required": true, + "responses": { + "default": { + "description": "List of region resources identified by their UUID", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Region" + "type": "object", + "additionalProperties": { + "allOf": [ + { + "$ref": "#/components/schemas/RegionResponseModel" + } + ] + } } } } - }, - "responses": { - "200": { - "$ref": "#/components/responses/200ActionResponse" - }, - "default": { - "$ref": "#/components/responses/ErrorResponse" + } + } + }, + "post": { + "tags": [ + "regions" + ], + "summary": "New Region", + "requestBody": { + "description": "API request payload", + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RegionPostModel" + } } } }, - "delete": { - "tags": [ - "regions" - ], - "summary": "Remove Region with supplied id", - "responses": { - "200": { - "$ref": "#/components/responses/200ActionResponse" - }, - "default": { - "$ref": "#/components/responses/ErrorResponse" - } + "responses": { + "201": { + "$ref": "#/components/responses/201ActionResponse" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + }, + "/resources/regions/{id}": { + "parameters": [ + { + "name": "id", + "in": "path", + "description": "region id", + "required": true, + "schema": { + "$ref": "#/components/schemas/SignalKUuid" + } + } + ], + "get": { + "tags": [ + "regions" + ], + "summary": "Retrieve region with supplied id", + "responses": { + "200": { + "$ref": "#/components/responses/RegionResponse" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" } } }, - "/resources/notes": { - "get": { - "tags": [ - "notes" - ], - "summary": "Retrieve note resources", - "parameters": [ - { - "$ref": "#/components/parameters/LimitParam" - }, - { - "$ref": "#/components/parameters/DistanceParam" - }, - { - "$ref": "#/components/parameters/BoundingBoxParam" - }, - { - "$ref": "#/components/parameters/PositionParam" - }, - { - "name": "href", - "in": "query", - "description": "Limit results to notes with matching resource reference", - "example": "/resources/waypoints/urn:mrn:signalk:uuid:ac3a3b2d-07e8-4f25-92bc-98e7c92f7f1a", - "required": false, - "explode": false, + "put": { + "tags": [ + "regions" + ], + "summary": "Add / update a new Region with supplied id", + "requestBody": { + "description": "Region resource entry", + "required": true, + "content": { + "application/json": { "schema": { - "$ref": "#/components/schemas/SignalKHref" - } - } - ], - "responses": { - "default": { - "description": "List of note resources identified by their UUID", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": { - "allOf": [ - { - "$ref": "#/components/schemas/NoteResponseModel" - } - ] - } - } - } + "$ref": "#/components/schemas/Region" } } } }, - "post": { - "tags": [ - "notes" - ], - "summary": "New Note", - "requestBody": { - "description": "Note resource entry", - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Note" - } - } - } + "responses": { + "200": { + "$ref": "#/components/responses/200ActionResponse" }, - "responses": { - "201": { - "$ref": "#/components/responses/201ActionResponse" - }, - "default": { - "$ref": "#/components/responses/ErrorResponse" - } + "default": { + "$ref": "#/components/responses/ErrorResponse" } } }, - "/resources/notes/{id}": { + "delete": { + "tags": [ + "regions" + ], + "summary": "Remove Region with supplied id", + "responses": { + "200": { + "$ref": "#/components/responses/200ActionResponse" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + }, + "/resources/notes": { + "get": { + "tags": [ + "notes" + ], + "summary": "Retrieve note resources", "parameters": [ { - "name": "id", - "in": "path", - "description": "note id", - "required": true, + "$ref": "#/components/parameters/LimitParam" + }, + { + "$ref": "#/components/parameters/DistanceParam" + }, + { + "$ref": "#/components/parameters/BoundingBoxParam" + }, + { + "$ref": "#/components/parameters/PositionParam" + }, + { + "name": "href", + "in": "query", + "description": "Limit results to notes with matching resource reference", + "example": "/resources/waypoints/urn:mrn:signalk:uuid:ac3a3b2d-07e8-4f25-92bc-98e7c92f7f1a", + "required": false, + "explode": false, "schema": { - "$ref": "#/components/schemas/SignalKUuid" + "$ref": "#/components/schemas/SignalKHref" } } ], - "get": { - "tags": [ - "notes" - ], - "summary": "Retrieve note with supplied id", - "responses": { - "200": { - "$ref": "#/components/responses/NoteResponse" - }, - "default": { - "$ref": "#/components/responses/ErrorResponse" - } - } - }, - "put": { - "tags": [ - "notes" - ], - "summary": "Add / update a new Note with supplied id", - "requestBody": { - "description": "Note resource entry", - "required": true, + "responses": { + "default": { + "description": "List of note resources identified by their UUID", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Note" + "type": "object", + "additionalProperties": { + "allOf": [ + { + "$ref": "#/components/schemas/NoteResponseModel" + } + ] + } } } } - }, - "responses": { - "200": { - "$ref": "#/components/responses/200ActionResponse" - }, - "default": { - "$ref": "#/components/responses/ErrorResponse" + } + } + }, + "post": { + "tags": [ + "notes" + ], + "summary": "New Note", + "requestBody": { + "description": "Note resource entry", + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Note" + } } } }, - "delete": { - "tags": [ - "notes" - ], - "summary": "Remove Note with supplied id", - "responses": { - "200": { - "$ref": "#/components/responses/200ActionResponse" - }, - "default": { - "$ref": "#/components/responses/ErrorResponse" - } + "responses": { + "201": { + "$ref": "#/components/responses/201ActionResponse" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + }, + "/resources/notes/{id}": { + "parameters": [ + { + "name": "id", + "in": "path", + "description": "note id", + "required": true, + "schema": { + "$ref": "#/components/schemas/SignalKUuid" + } + } + ], + "get": { + "tags": [ + "notes" + ], + "summary": "Retrieve note with supplied id", + "responses": { + "200": { + "$ref": "#/components/responses/NoteResponse" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" } } }, - "/resources/charts": { - "get": { - "tags": [ - "charts" - ], - "summary": "Retrieve chart resources", - "responses": { - "default": { - "description": "List of chart resources identified by their UUID", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": { - "allOf": [ - { - "$ref": "#/components/schemas/ChartResponseModel" - } - ] - } - } - } + "put": { + "tags": [ + "notes" + ], + "summary": "Add / update a new Note with supplied id", + "requestBody": { + "description": "Note resource entry", + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Note" } } } }, - "post": { - "tags": [ - "charts" - ], - "summary": "New Chart", - "requestBody": { - "description": "Chart resource entry", - "required": true, + "responses": { + "200": { + "$ref": "#/components/responses/200ActionResponse" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + }, + "delete": { + "tags": [ + "notes" + ], + "summary": "Remove Note with supplied id", + "responses": { + "200": { + "$ref": "#/components/responses/200ActionResponse" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + }, + "/resources/charts": { + "get": { + "tags": [ + "charts" + ], + "summary": "Retrieve chart resources", + "responses": { + "default": { + "description": "List of chart resources identified by their UUID", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Chart" + "type": "object", + "additionalProperties": { + "allOf": [ + { + "$ref": "#/components/schemas/ChartResponseModel" + } + ] + } } } } - }, - "responses": { - "201": { - "$ref": "#/components/responses/201ActionResponse" - }, - "default": { - "$ref": "#/components/responses/ErrorResponse" - } } } }, - "/resources/charts/{id}": { - "parameters": [ - { - "name": "id", - "in": "path", - "description": "chart id", - "required": true, - "schema": { - "type": "string" - } - } + "post": { + "tags": [ + "charts" ], - "get": { - "tags": [ - "charts" - ], - "summary": "Retrieve chart with supplied id", - "responses": { - "200": { - "$ref": "#/components/responses/ChartResponse" - }, - "default": { - "$ref": "#/components/responses/ErrorResponse" + "summary": "New Chart", + "requestBody": { + "description": "Chart resource entry", + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Chart" + } } } }, - "put": { - "tags": [ - "charts" - ], - "summary": "Add / update a new Chart with supplied id", - "requestBody": { - "description": "Chart resource entry", - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Chart" - } + "responses": { + "201": { + "$ref": "#/components/responses/201ActionResponse" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + }, + "/resources/charts/{id}": { + "parameters": [ + { + "name": "id", + "in": "path", + "description": "chart id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "get": { + "tags": [ + "charts" + ], + "summary": "Retrieve chart with supplied id", + "responses": { + "200": { + "$ref": "#/components/responses/ChartResponse" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + }, + "put": { + "tags": [ + "charts" + ], + "summary": "Add / update a new Chart with supplied id", + "requestBody": { + "description": "Chart resource entry", + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Chart" } } + } + }, + "responses": { + "200": { + "$ref": "#/components/responses/200ActionResponse" }, - "responses": { - "200": { - "$ref": "#/components/responses/200ActionResponse" - }, - "default": { - "$ref": "#/components/responses/ErrorResponse" - } + "default": { + "$ref": "#/components/responses/ErrorResponse" } } } } } +} From 2d28bfe921a408ffafa32d1f6dc6274689cca36e Mon Sep 17 00:00:00 2001 From: panaaj <38519157+panaaj@users.noreply.github.com> Date: Mon, 11 Apr 2022 09:18:54 +0930 Subject: [PATCH 13/63] Remove chart post api definition. Chart id should be meaningful. --- src/api/resources/openApi.json | 25 ------------------------- 1 file changed, 25 deletions(-) diff --git a/src/api/resources/openApi.json b/src/api/resources/openApi.json index 00335b647..ca552ed5a 100644 --- a/src/api/resources/openApi.json +++ b/src/api/resources/openApi.json @@ -1721,31 +1721,6 @@ } } } - }, - "post": { - "tags": [ - "charts" - ], - "summary": "New Chart", - "requestBody": { - "description": "Chart resource entry", - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Chart" - } - } - } - }, - "responses": { - "201": { - "$ref": "#/components/responses/201ActionResponse" - }, - "default": { - "$ref": "#/components/responses/ErrorResponse" - } - } } }, "/resources/charts/{id}": { From 71eb3d453a962a3edb8b8d26b9ffb1ac8bf63a63 Mon Sep 17 00:00:00 2001 From: Teppo Kurki Date: Mon, 18 Apr 2022 20:06:19 +0300 Subject: [PATCH 14/63] feature: dynamic servers in openApis Instead of the fixed servers in openApi.json files we can serve servers relative to the host used in the request. Introduce ts OpenApiRecord to keep metadata, keeping the openapi.json files schema compliant. I tried first adding the metadata there, but the schema based validation did not like extra properties there." --- src/api/course/openApi.ts | 7 +++++++ src/api/resources/openApi.ts | 7 +++++++ src/api/swagger.ts | 34 +++++++++++++++++++++++++--------- 3 files changed, 39 insertions(+), 9 deletions(-) create mode 100644 src/api/course/openApi.ts create mode 100644 src/api/resources/openApi.ts diff --git a/src/api/course/openApi.ts b/src/api/course/openApi.ts new file mode 100644 index 000000000..5df5f5968 --- /dev/null +++ b/src/api/course/openApi.ts @@ -0,0 +1,7 @@ +import courseApiDoc from './openApi.json' + +export const courseApiRecord = { + name: 'course', + path: '/signalk/v2/api/vessels/self/navigation', + apiDoc: courseApiDoc +} diff --git a/src/api/resources/openApi.ts b/src/api/resources/openApi.ts new file mode 100644 index 000000000..165152f53 --- /dev/null +++ b/src/api/resources/openApi.ts @@ -0,0 +1,7 @@ +import resourcesApiDoc from './openApi.json' + +export const resourcesApiRecord = { + name: 'resources', + path: '/signalk/v2/api', + apiDoc: resourcesApiDoc +} diff --git a/src/api/swagger.ts b/src/api/swagger.ts index 4cee38948..066eeece1 100644 --- a/src/api/swagger.ts +++ b/src/api/swagger.ts @@ -1,16 +1,25 @@ -import { Request, Response } from 'express' +import { NextFunction, Request, Response } from 'express' import swaggerUi from 'swagger-ui-express' import { SERVERROUTESPREFIX } from '../constants' -import courseApiDoc from './course/openApi.json' -import resourcesApiDoc from './resources/openApi.json' +import { courseApiRecord } from './course/openApi' +import { resourcesApiRecord } from './resources/openApi' -const apiDocs: { - [key: string]: any -} = { - course: courseApiDoc, - resources: resourcesApiDoc +interface OpenApiRecord { + name: string + path: string + apiDoc: any } +const apiDocs: { + [name: string]: OpenApiRecord +} = [courseApiRecord, resourcesApiRecord].reduce( + (acc: any, apiRecord: OpenApiRecord) => { + acc[apiRecord.name] = apiRecord + return acc + }, + {} +) + export function mountSwaggerUi(app: any, path: string) { app.use( path, @@ -29,7 +38,14 @@ export function mountSwaggerUi(app: any, path: string) { `${SERVERROUTESPREFIX}/openapi/:api`, (req: Request, res: Response) => { if (apiDocs[req.params.api]) { - res.json(apiDocs[req.params.api]) + apiDocs[req.params.api].apiDoc.servers = [ + { + url: `${req.protocol}://${req.get('Host')}${ + apiDocs[req.params.api].path + }` + } + ] + res.json(apiDocs[req.params.api].apiDoc) } else { res.status(404) res.send('Not found') From 6146739189be3c4a84f80294ce4ff3e832b88108 Mon Sep 17 00:00:00 2001 From: panaaj <38519157+panaaj@users.noreply.github.com> Date: Tue, 19 Apr 2022 10:54:17 +0930 Subject: [PATCH 15/63] improve error message when unable to set course. setDestination, activateRoute throw errors with appropriate message. --- src/api/course/index.ts | 77 +++++++++++++++++++++++------------------ 1 file changed, 44 insertions(+), 33 deletions(-) diff --git a/src/api/course/index.ts b/src/api/course/index.ts index 61c7c166e..8f5201c35 100644 --- a/src/api/course/index.ts +++ b/src/api/course/index.ts @@ -222,12 +222,20 @@ export class CourseApi { return } - const result = await this.setDestination(req.body) - if (result) { - this.emitCourseInfo() - res.status(200).json(Responses.ok) - } else { - res.status(400).json(Responses.invalid) + try { + const result = await this.setDestination(req.body) + if (result) { + this.emitCourseInfo() + res.status(200).json(Responses.ok) + } else { + res.status(400).json(Responses.invalid) + } + } catch (error) { + res.status(400).json({ + state: 'FAILED', + statusCode: 400, + message: (error as any).message + }) } } ) @@ -256,12 +264,20 @@ export class CourseApi { res.status(403).json(Responses.unauthorised) return } - const result = await this.activateRoute(req.body) - if (result) { - this.emitCourseInfo() - res.status(200).json(Responses.ok) - } else { - res.status(400).json(Responses.invalid) + try { + const result = await this.activateRoute(req.body) + if (result) { + this.emitCourseInfo() + res.status(200).json(Responses.ok) + } else { + res.status(400).json(Responses.invalid) + } + } catch (error) { + res.status(400).json({ + state: 'FAILED', + statusCode: 400, + message: (error as any).message + }) } } ) @@ -413,15 +429,15 @@ export class CourseApi { if (route.href) { rte = await this.getRoute(route.href) if (!rte) { - console.log(`** Could not retrieve route information for ${route.href}`) - return false + throw new Error( + `** Could not retrieve route information for ${route.href}` + ) } if (!Array.isArray(rte.feature?.geometry?.coordinates)) { - debug(`** Invalid route coordinate data! (${route.href})`) - return false + throw new Error(`Invalid route coordinate data! (${route.href})`) } } else { - return false + throw new Error('Route information not supplied!') } const newCourse: CourseInfo = { ...this.courseInfo } @@ -462,11 +478,10 @@ export class CourseApi { this.courseInfo.previousPoint.position = position.value this.courseInfo.previousPoint.type = `VesselPosition` } else { - console.log(`** Error: unable to retrieve vessel position!`) - return false + throw new Error(`Error: Unable to retrieve vessel position!`) } } catch (err) { - return false + throw new Error(`Error: Unable to retrieve vessel position!`) } } else { newCourse.previousPoint.position = this.getRoutePoint( @@ -511,16 +526,13 @@ export class CourseApi { newCourse.nextPoint.href = dest.href newCourse.nextPoint.type = 'Waypoint' } else { - debug(`** Invalid waypoint coordinate data! (${dest.href})`) - return false + throw new Error(`Invalid waypoint coordinate data! (${dest.href})`) } } catch (err) { - console.log(`** Error retrieving and validating ${dest.href}`) - return false + throw new Error(`Error retrieving and validating ${dest.href}`) } } else { - debug(`** Invalid href! (${dest.href})`) - return false + throw new Error(`Invalid href! (${dest.href})`) } } else if (dest.position) { newCourse.nextPoint.href = null @@ -528,11 +540,10 @@ export class CourseApi { if (isValidCoordinate(dest.position)) { newCourse.nextPoint.position = dest.position } else { - debug(`** Error: position is not valid`) - return false + throw new Error(`Error: position is not valid`) } } else { - return false + throw new Error(`Destination not provided!`) } // clear activeRoute values @@ -550,12 +561,12 @@ export class CourseApi { newCourse.previousPoint.type = `VesselPosition` newCourse.previousPoint.href = null } else { - debug(`** Error: navigation.position.value is undefined! (${position})`) - return false + throw new Error( + `Error: navigation.position.value is undefined! (${position})` + ) } } catch (err) { - console.log(`** Error: unable to retrieve vessel position!`) - return false + throw new Error(`Error: Unable to retrieve vessel position!`) } this.courseInfo = newCourse From 9d39b6d79a5710263099432389a0fef4bb2ae23b Mon Sep 17 00:00:00 2001 From: Teppo Kurki Date: Thu, 21 Apr 2022 21:58:17 +0300 Subject: [PATCH 16/63] Use Freeboard beta --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index cc4a22656..7c69d7389 100644 --- a/package.json +++ b/package.json @@ -134,7 +134,7 @@ "ws": "^7.0.0" }, "optionalDependencies": { - "@signalk/freeboard-sk": "^1.0.0", + "@signalk/freeboard-sk": "^2.0.0-beta.1", "@signalk/instrumentpanel": "0.x", "@signalk/set-system-time": "^1.2.0", "@signalk/signalk-to-nmea0183": "^1.0.0", From 096bbdce492fc819763431de8f6e6e5394dc668e Mon Sep 17 00:00:00 2001 From: panaaj <38519157+panaaj@users.noreply.github.com> Date: Fri, 22 Apr 2022 14:41:04 +0930 Subject: [PATCH 17/63] move `startTime` up a level Move out from under `activeRooute` --- src/api/course/index.ts | 21 +++++++++++---------- src/api/course/openApi.json | 8 ++++---- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/src/api/course/index.ts b/src/api/course/index.ts index 8f5201c35..06ebcead0 100644 --- a/src/api/course/index.ts +++ b/src/api/course/index.ts @@ -46,9 +46,9 @@ interface ActiveRoute extends DestinationBase { } interface CourseInfo { + startTime: string | null activeRoute: { href: string | null - startTime: string | null pointIndex: number pointTotal: number reverse: boolean @@ -70,9 +70,9 @@ export class CourseApi { private server: CourseApplication private courseInfo: CourseInfo = { + startTime: null, activeRoute: { href: null, - startTime: null, pointIndex: 0, pointTotal: 0, reverse: false @@ -445,12 +445,12 @@ export class CourseApi { // set activeroute newCourse.activeRoute.href = route.href + newCourse.startTime = new Date().toISOString() + if (this.isValidArrivalCircle(route.arrivalCircle as number)) { newCourse.nextPoint.arrivalCircle = route.arrivalCircle as number } - newCourse.activeRoute.startTime = new Date().toISOString() - if (typeof route.reverse === 'boolean') { newCourse.activeRoute.reverse = route.reverse } @@ -500,6 +500,8 @@ export class CourseApi { private async setDestination(dest: Destination): Promise { const newCourse: CourseInfo = { ...this.courseInfo } + newCourse.startTime = new Date().toISOString() + // set nextPoint if (this.isValidArrivalCircle(dest.arrivalCircle)) { newCourse.nextPoint.arrivalCircle = dest.arrivalCircle as number @@ -548,7 +550,6 @@ export class CourseApi { // clear activeRoute values newCourse.activeRoute.href = null - newCourse.activeRoute.startTime = null newCourse.activeRoute.pointIndex = 0 newCourse.activeRoute.pointTotal = 0 newCourse.activeRoute.reverse = false @@ -574,8 +575,8 @@ export class CourseApi { } private clearDestination() { + this.courseInfo.startTime = null this.courseInfo.activeRoute.href = null - this.courseInfo.activeRoute.startTime = null this.courseInfo.activeRoute.pointIndex = 0 this.courseInfo.activeRoute.pointTotal = 0 this.courseInfo.activeRoute.reverse = false @@ -666,12 +667,12 @@ export class CourseApi { debug(this.courseInfo) values.push({ - path: `${navPath}.activeRoute.href`, - value: this.courseInfo.activeRoute.href + path: `${navPath}.startTime`, + value: this.courseInfo.startTime }) values.push({ - path: `${navPath}.activeRoute.startTime`, - value: this.courseInfo.activeRoute.startTime + path: `${navPath}.activeRoute.href`, + value: this.courseInfo.activeRoute.href }) values.push({ path: `${navPath}.activeRoute.pointIndex`, diff --git a/src/api/course/openApi.json b/src/api/course/openApi.json index dcd003d08..0409c1918 100644 --- a/src/api/course/openApi.json +++ b/src/api/course/openApi.json @@ -259,16 +259,19 @@ "description": "base model for course response", "type": "object", "required": [ + "startTime", "activeRoute", "nextPoint", "previousPoint" ], "properties": { + "startTime": { + "type": "string" + }, "activeRoute": { "type": "object", "required": [ "href", - "startTime", "pointIndex", "pointTotal", "reverse" @@ -277,9 +280,6 @@ "href": { "$ref": "#/components/schemas/SignalKHrefRoute" }, - "startTime": { - "type": "string" - }, "pointIndex": { "type": "number", "minimum": 0, From 23d36428e65ba0ccd032fc85a100f2209bbadf5d Mon Sep 17 00:00:00 2001 From: panaaj <38519157+panaaj@users.noreply.github.com> Date: Fri, 22 Apr 2022 14:42:26 +0930 Subject: [PATCH 18/63] update references to startTime No longer under `activeRoute` --- test/course.ts | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/test/course.ts b/test/course.ts index 99ef37214..91320b89b 100644 --- a/test/course.ts +++ b/test/course.ts @@ -32,7 +32,7 @@ describe('Course Api', () => { value: null }, { - path: 'navigation.course.activeRoute.startTime', + path: 'navigation.course.startTime', value: null }, { @@ -84,9 +84,9 @@ describe('Course Api', () => { await selfGetJson('navigation/course').then(data => { data.should.deep.equal({ + startTime: null, activeRoute: { href: null, - startTime: null, pointIndex: 0, pointTotal: 0, reverse: false @@ -189,7 +189,7 @@ describe('Course Api', () => { let expectedPathValues = [ { path: 'navigation.course.activeRoute.href', value: null }, - { path: 'navigation.course.activeRoute.startTime', value: null }, + { path: 'navigation.course.startTime', value: null }, { path: 'navigation.course.activeRoute.pointIndex', value: 0 }, { path: 'navigation.course.activeRoute.pointTotal', value: 0 }, { path: 'navigation.course.activeRoute.reverse', value: false }, @@ -218,9 +218,9 @@ describe('Course Api', () => { await selfGetJson('navigation/course').then(data => { data.should.deep.equal({ + startTime: null, activeRoute: { href: null, - startTime: null, pointIndex: 0, pointTotal: 0, reverse: false @@ -249,7 +249,7 @@ describe('Course Api', () => { value: null }, { - path: 'navigation.course.activeRoute.startTime', + path: 'navigation.course.startTime', value: null }, { @@ -295,9 +295,9 @@ describe('Course Api', () => { await selfGetJson('navigation/course').then(data => { data.should.deep.equal({ + startTime: null, activeRoute: { href: null, - startTime: null, pointIndex: 0, pointTotal: 0, reverse: false @@ -407,11 +407,11 @@ describe('Course Api', () => { deltaHasPathValue(courseDelta, path, value) ) courseDelta.updates[0].values.find( - (x: any) => x.path === 'navigation.course.activeRoute.startTime' + (x: any) => x.path === 'navigation.course.startTime' ).should.not.be.undefined await selfGetJson('navigation/course').then(data => { - delete data.activeRoute.startTime + delete data.startTime data.should.deep.equal({ activeRoute: { href, @@ -501,7 +501,7 @@ describe('Course Api', () => { value: null }, { - path: 'navigation.course.activeRoute.startTime', + path: 'navigation.course.startTime', value: null }, { @@ -547,9 +547,9 @@ describe('Course Api', () => { await selfGetJson('navigation/course').then(data => { data.should.deep.equal({ + startTime: null, activeRoute: { href: null, - startTime: null, pointIndex: 0, pointTotal: 0, reverse: false From eeb6432ebea8c4b43bfb2897d3bc9e2581b7b7c3 Mon Sep 17 00:00:00 2001 From: panaaj <38519157+panaaj@users.noreply.github.com> Date: Fri, 22 Apr 2022 14:53:45 +0930 Subject: [PATCH 19/63] add startTime example --- src/api/course/openApi.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/api/course/openApi.json b/src/api/course/openApi.json index 0409c1918..1577b304b 100644 --- a/src/api/course/openApi.json +++ b/src/api/course/openApi.json @@ -266,7 +266,8 @@ ], "properties": { "startTime": { - "type": "string" + "type": "string", + "example": "2022-04-22T05:02:56.484Z" }, "activeRoute": { "type": "object", From 7ef6327df699abab87bf6d0384df09b26a91f160 Mon Sep 17 00:00:00 2001 From: panaaj <38519157+panaaj@users.noreply.github.com> Date: Fri, 22 Apr 2022 15:37:46 +0930 Subject: [PATCH 20/63] set pointIndex, pointTotal and reverse to null When destination / active route is cleared --- src/api/course/index.ts | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/src/api/course/index.ts b/src/api/course/index.ts index 06ebcead0..f6c0179e2 100644 --- a/src/api/course/index.ts +++ b/src/api/course/index.ts @@ -49,9 +49,9 @@ interface CourseInfo { startTime: string | null activeRoute: { href: string | null - pointIndex: number - pointTotal: number - reverse: boolean + pointIndex: number | null + pointTotal: number | null + reverse: boolean | null } nextPoint: { href: string | null @@ -318,6 +318,7 @@ export class CourseApi { if (req.params.action === 'nextPoint') { if ( + typeof this.courseInfo.activeRoute.pointIndex === 'number' && typeof req.body.value === 'number' && (req.body.value === 1 || req.body.value === -1) ) { @@ -378,7 +379,7 @@ export class CourseApi { // set new destination this.courseInfo.nextPoint.position = this.getRoutePoint( rte, - this.courseInfo.activeRoute.pointIndex, + (this.courseInfo.activeRoute.pointIndex as number), this.courseInfo.activeRoute.reverse ) this.courseInfo.nextPoint.type = `RoutePoint` @@ -403,7 +404,7 @@ export class CourseApi { } else { this.courseInfo.previousPoint.position = this.getRoutePoint( rte, - this.courseInfo.activeRoute.pointIndex - 1, + (this.courseInfo.activeRoute.pointIndex as number) - 1, this.courseInfo.activeRoute.reverse ) this.courseInfo.previousPoint.type = `RoutePoint` @@ -417,9 +418,9 @@ export class CourseApi { private calcReversedIndex(): number { return ( - this.courseInfo.activeRoute.pointTotal - + (this.courseInfo.activeRoute.pointTotal as number) - 1 - - this.courseInfo.activeRoute.pointIndex + (this.courseInfo.activeRoute.pointIndex as number) ) } @@ -577,9 +578,9 @@ export class CourseApi { private clearDestination() { this.courseInfo.startTime = null this.courseInfo.activeRoute.href = null - this.courseInfo.activeRoute.pointIndex = 0 - this.courseInfo.activeRoute.pointTotal = 0 - this.courseInfo.activeRoute.reverse = false + this.courseInfo.activeRoute.pointIndex = null + this.courseInfo.activeRoute.pointTotal = null + this.courseInfo.activeRoute.reverse = null this.courseInfo.nextPoint.href = null this.courseInfo.nextPoint.type = null this.courseInfo.nextPoint.position = null @@ -629,7 +630,7 @@ export class CourseApi { } } - private getRoutePoint(rte: any, index: number, reverse: boolean) { + private getRoutePoint(rte: any, index: number, reverse: boolean | null) { const pos = reverse ? rte.feature.geometry.coordinates[ rte.feature.geometry.coordinates.length - (index + 1) From 9df79268fb1678c660ddd8329851ac6b90680d87 Mon Sep 17 00:00:00 2001 From: panaaj <38519157+panaaj@users.noreply.github.com> Date: Fri, 22 Apr 2022 15:38:09 +0930 Subject: [PATCH 21/63] update catch error type --- src/api/resources/index.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/api/resources/index.ts b/src/api/resources/index.ts index df74fc03a..eda73d89b 100644 --- a/src/api/resources/index.ts +++ b/src/api/resources/index.ts @@ -215,7 +215,7 @@ export class ResourcesApi { res.status(400).json({ state: 'FAILED', statusCode: 400, - message: e.message + message: (e as Error).message }) return } @@ -267,7 +267,7 @@ export class ResourcesApi { res.status(400).json({ state: 'FAILED', statusCode: 400, - message: e.message + message: (e as Error).message }) return } @@ -355,7 +355,7 @@ export class ResourcesApi { res.status(400).json({ state: 'FAILED', statusCode: 400, - message: e.message + message: (e as Error).message }) return } From 6a61f712b32aa92ecd24897476eb5047efd24092 Mon Sep 17 00:00:00 2001 From: panaaj <38519157+panaaj@users.noreply.github.com> Date: Fri, 22 Apr 2022 15:44:10 +0930 Subject: [PATCH 22/63] fix tests for null pointIndex, pointTotal, reverse --- test/course.ts | 48 ++++++++++++++++++++++++------------------------ 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/test/course.ts b/test/course.ts index 91320b89b..fa3cf24af 100644 --- a/test/course.ts +++ b/test/course.ts @@ -37,15 +37,15 @@ describe('Course Api', () => { }, { path: 'navigation.course.activeRoute.pointIndex', - value: 0 + value: null }, { path: 'navigation.course.activeRoute.pointTotal', - value: 0 + value: null }, { path: 'navigation.course.activeRoute.reverse', - value: false + value: null }, { path: 'navigation.course.nextPoint.href', @@ -87,9 +87,9 @@ describe('Course Api', () => { startTime: null, activeRoute: { href: null, - pointIndex: 0, - pointTotal: 0, - reverse: false + pointIndex: null, + pointTotal: null, + reverse: null }, nextPoint: { href: null, @@ -190,9 +190,9 @@ describe('Course Api', () => { let expectedPathValues = [ { path: 'navigation.course.activeRoute.href', value: null }, { path: 'navigation.course.startTime', value: null }, - { path: 'navigation.course.activeRoute.pointIndex', value: 0 }, - { path: 'navigation.course.activeRoute.pointTotal', value: 0 }, - { path: 'navigation.course.activeRoute.reverse', value: false }, + { path: 'navigation.course.activeRoute.pointIndex', value: null }, + { path: 'navigation.course.activeRoute.pointTotal', value: null }, + { path: 'navigation.course.activeRoute.reverse', value: null }, { path: 'navigation.course.nextPoint.href', value: href @@ -221,9 +221,9 @@ describe('Course Api', () => { startTime: null, activeRoute: { href: null, - pointIndex: 0, - pointTotal: 0, - reverse: false + pointIndex: null, + pointTotal: null, + reverse: null }, nextPoint: { href, @@ -254,15 +254,15 @@ describe('Course Api', () => { }, { path: 'navigation.course.activeRoute.pointIndex', - value: 0 + value: null }, { path: 'navigation.course.activeRoute.pointTotal', - value: 0 + value: null }, { path: 'navigation.course.activeRoute.reverse', - value: false + value: null }, { path: 'navigation.course.nextPoint.href', @@ -298,9 +298,9 @@ describe('Course Api', () => { startTime: null, activeRoute: { href: null, - pointIndex: 0, - pointTotal: 0, - reverse: false + pointIndex: null, + pointTotal: null, + reverse: null }, nextPoint: { href: null, @@ -506,15 +506,15 @@ describe('Course Api', () => { }, { path: 'navigation.course.activeRoute.pointIndex', - value: 0 + value: null }, { path: 'navigation.course.activeRoute.pointTotal', - value: 0 + value: null }, { path: 'navigation.course.activeRoute.reverse', - value: false + value: null }, { path: 'navigation.course.nextPoint.href', @@ -550,9 +550,9 @@ describe('Course Api', () => { startTime: null, activeRoute: { href: null, - pointIndex: 0, - pointTotal: 0, - reverse: false + pointIndex: null, + pointTotal: null, + reverse: null }, nextPoint: { href: null, From 3101b30046237902433eba5814a316dc395bb225 Mon Sep 17 00:00:00 2001 From: Teppo Kurki Date: Sat, 23 Apr 2022 09:01:44 +0300 Subject: [PATCH 23/63] fix: correct nulls in pointIndex|Total and reverse --- src/api/course/index.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/api/course/index.ts b/src/api/course/index.ts index f6c0179e2..f75caf32a 100644 --- a/src/api/course/index.ts +++ b/src/api/course/index.ts @@ -73,9 +73,9 @@ export class CourseApi { startTime: null, activeRoute: { href: null, - pointIndex: 0, - pointTotal: 0, - reverse: false + pointIndex: null, + pointTotal: null, + reverse: null }, nextPoint: { href: null, @@ -551,9 +551,9 @@ export class CourseApi { // clear activeRoute values newCourse.activeRoute.href = null - newCourse.activeRoute.pointIndex = 0 - newCourse.activeRoute.pointTotal = 0 - newCourse.activeRoute.reverse = false + newCourse.activeRoute.pointIndex = null + newCourse.activeRoute.pointTotal = null + newCourse.activeRoute.reverse = null // set previousPoint try { From 17bcd524483be115fedc34d63030e9ffb62dcce0 Mon Sep 17 00:00:00 2001 From: Teppo Kurki Date: Sat, 23 Apr 2022 09:02:46 +0300 Subject: [PATCH 24/63] fix: always set reverse when activating route --- src/api/course/index.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/api/course/index.ts b/src/api/course/index.ts index f75caf32a..3e6ded5d2 100644 --- a/src/api/course/index.ts +++ b/src/api/course/index.ts @@ -452,9 +452,7 @@ export class CourseApi { newCourse.nextPoint.arrivalCircle = route.arrivalCircle as number } - if (typeof route.reverse === 'boolean') { - newCourse.activeRoute.reverse = route.reverse - } + newCourse.activeRoute.reverse = !!route.reverse newCourse.activeRoute.pointIndex = this.parsePointIndex( route.pointIndex as number, From 091adc04e35e5ec3b8db21daa32110a7b219a097 Mon Sep 17 00:00:00 2001 From: Teppo Kurki Date: Sat, 23 Apr 2022 09:05:17 +0300 Subject: [PATCH 25/63] fix startTime assertions --- test/course.ts | 17 +++++++++-------- test/ts-servertestutilities.ts | 2 ++ 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/test/course.ts b/test/course.ts index fa3cf24af..8327aa00c 100644 --- a/test/course.ts +++ b/test/course.ts @@ -1,7 +1,7 @@ import { strict as assert } from 'assert' import chai from 'chai' import resourcesOpenApi from '../src/api/resources/openApi.json' -import { deltaHasPathValue, startServer } from './ts-servertestutilities' +import { DATETIME_REGEX, deltaHasPathValue, startServer } from './ts-servertestutilities' chai.should() describe('Course Api', () => { @@ -31,10 +31,6 @@ describe('Course Api', () => { path: 'navigation.course.activeRoute.href', value: null }, - { - path: 'navigation.course.startTime', - value: null - }, { path: 'navigation.course.activeRoute.pointIndex', value: null @@ -83,8 +79,9 @@ describe('Course Api', () => { ) await selfGetJson('navigation/course').then(data => { + data.startTime.should.match(DATETIME_REGEX) + delete data.startTime data.should.deep.equal({ - startTime: null, activeRoute: { href: null, pointIndex: null, @@ -189,7 +186,6 @@ describe('Course Api', () => { let expectedPathValues = [ { path: 'navigation.course.activeRoute.href', value: null }, - { path: 'navigation.course.startTime', value: null }, { path: 'navigation.course.activeRoute.pointIndex', value: null }, { path: 'navigation.course.activeRoute.pointTotal', value: null }, { path: 'navigation.course.activeRoute.reverse', value: null }, @@ -216,9 +212,14 @@ describe('Course Api', () => { deltaHasPathValue(courseDelta, path, value) ) + const pathValue = courseDelta.updates[0].values.find((x: any) => x.path === 'navigation.course.startTime') + pathValue.value.should.match(DATETIME_REGEX) + + await selfGetJson('navigation/course').then(data => { + data.startTime.should.match(DATETIME_REGEX) + delete data.startTime data.should.deep.equal({ - startTime: null, activeRoute: { href: null, pointIndex: null, diff --git a/test/ts-servertestutilities.ts b/test/ts-servertestutilities.ts index ef8f6c4d0..1a71d67d6 100644 --- a/test/ts-servertestutilities.ts +++ b/test/ts-servertestutilities.ts @@ -10,6 +10,8 @@ import { } from './servertestutilities' import { expect } from 'chai' +export const DATETIME_REGEX = /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(\.\d+)Z?$/ + const emptyConfigDirectory = () => Promise.all( ['serverstate/course', 'resources', 'plugin-config-data', 'baseDeltas.json'] From 3c86ed27b225af410163a33e69c750cebb23b5d5 Mon Sep 17 00:00:00 2001 From: Teppo Kurki Date: Sat, 23 Apr 2022 09:06:09 +0300 Subject: [PATCH 26/63] linter --- src/api/course/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/api/course/index.ts b/src/api/course/index.ts index 3e6ded5d2..932757a88 100644 --- a/src/api/course/index.ts +++ b/src/api/course/index.ts @@ -379,7 +379,7 @@ export class CourseApi { // set new destination this.courseInfo.nextPoint.position = this.getRoutePoint( rte, - (this.courseInfo.activeRoute.pointIndex as number), + this.courseInfo.activeRoute.pointIndex as number, this.courseInfo.activeRoute.reverse ) this.courseInfo.nextPoint.type = `RoutePoint` From 949be9e74e0da075a01a9a9f7c1c30fdd4d1fa28 Mon Sep 17 00:00:00 2001 From: Teppo Kurki Date: Mon, 25 Apr 2022 08:50:17 +0300 Subject: [PATCH 27/63] add files for heroku demo setup --- docker/Dockerfile_heroku_api_demo | 10 ++++++++++ docker/startup_heroku_demo.sh | 5 +++++ 2 files changed, 15 insertions(+) create mode 100644 docker/Dockerfile_heroku_api_demo create mode 100644 docker/startup_heroku_demo.sh diff --git a/docker/Dockerfile_heroku_api_demo b/docker/Dockerfile_heroku_api_demo new file mode 100644 index 000000000..befb4caaf --- /dev/null +++ b/docker/Dockerfile_heroku_api_demo @@ -0,0 +1,10 @@ +#docker buildx build --platform linux/amd64 -f Dockerfile_heroku_api_demo -t registry.heroku.com/signalk-course-resources-api/web . +#docker push registry.heroku.com/signalk-course-resources-api/web +#heroku container:release web -a signalk-course-resources-api +FROM signalk/signalk-server:resources_course_api + +WORKDIR /home/node/signalk +USER root +COPY startup_heroku_demo.sh startup.sh +RUN chmod +x startup.sh +USER node diff --git a/docker/startup_heroku_demo.sh b/docker/startup_heroku_demo.sh new file mode 100644 index 000000000..2b108f9d0 --- /dev/null +++ b/docker/startup_heroku_demo.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env sh +service dbus restart +/usr/sbin/avahi-daemon -k +/usr/sbin/avahi-daemon --no-drop-root & +/home/node/signalk/bin/signalk-server --sample-nmea0183-data From 86d6b533f49c6a2b87393e0d72997560dfd02e4f Mon Sep 17 00:00:00 2001 From: Teppo Kurki Date: Mon, 25 Apr 2022 22:04:43 +0300 Subject: [PATCH 28/63] add course-provider plugin --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 7c69d7389..f5313201a 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,7 @@ "packages/resources-provider-plugin" ], "dependencies": { + "@signalk/course-provider": "^1.0.0-beta.1", "@signalk/n2k-signalk": "^2.0.0", "@signalk/nmea0183-signalk": "^3.0.0", "@signalk/resources-provider": "github:SignalK/resources-provider-plugin", From 0bd64234f6d2b469ec3484fd6051db068be9a971 Mon Sep 17 00:00:00 2001 From: Teppo Kurki Date: Mon, 25 Apr 2022 22:07:02 +0300 Subject: [PATCH 29/63] add plugin settings files Demo starts with active route and a waypoint. --- docker/Dockerfile_heroku_api_demo | 9 ++++++++- docker/course-data.json | 10 ++++++++++ docker/resources-provider.json | 12 ++++++++++++ .../ad825f6c-1ae9-4f76-abc4-df2866b14b78 | 1 + .../da825f6c-1ae9-4f76-abc4-df2866b14b78 | 1 + .../ac3a3b2d-07e8-4f25-92bc-98e7c92f7f1a | 17 +++++++++++++++++ .../afe46290-aa98-4d2f-9c04-d199ca64942e | 18 ++++++++++++++++++ docker/serverstate/course/settings.json | 1 + 8 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 docker/course-data.json create mode 100644 docker/resources-provider.json create mode 100644 docker/resources/routes/ad825f6c-1ae9-4f76-abc4-df2866b14b78 create mode 100644 docker/resources/routes/da825f6c-1ae9-4f76-abc4-df2866b14b78 create mode 100644 docker/resources/waypoints/ac3a3b2d-07e8-4f25-92bc-98e7c92f7f1a create mode 100644 docker/resources/waypoints/afe46290-aa98-4d2f-9c04-d199ca64942e create mode 100644 docker/serverstate/course/settings.json diff --git a/docker/Dockerfile_heroku_api_demo b/docker/Dockerfile_heroku_api_demo index befb4caaf..9c32405d3 100644 --- a/docker/Dockerfile_heroku_api_demo +++ b/docker/Dockerfile_heroku_api_demo @@ -3,8 +3,15 @@ #heroku container:release web -a signalk-course-resources-api FROM signalk/signalk-server:resources_course_api -WORKDIR /home/node/signalk USER root + +WORKDIR /home/node/signalk COPY startup_heroku_demo.sh startup.sh RUN chmod +x startup.sh + +COPY resources /home/node/.signalk/resources +COPY resources-provider.json /home/node/.signalk/plugin-config-data/ +COPY course-data.json /home/node/.signalk/plugin-config-data/ +COPY serverstate /home/node/.signalk/serverstate + USER node diff --git a/docker/course-data.json b/docker/course-data.json new file mode 100644 index 000000000..6910965c9 --- /dev/null +++ b/docker/course-data.json @@ -0,0 +1,10 @@ +{ + "configuration": { + "notifications": {}, + "calculations": { + "method": "Rhumbline", + "autopilot": true + } + }, + "enabled": true +} \ No newline at end of file diff --git a/docker/resources-provider.json b/docker/resources-provider.json new file mode 100644 index 000000000..b562c5a87 --- /dev/null +++ b/docker/resources-provider.json @@ -0,0 +1,12 @@ +{ + "configuration": { + "standard": { + "routes": true, + "waypoints": true, + "notes": true, + "regions": true + }, + "custom": [], + "path": "./resources" + } +} \ No newline at end of file diff --git a/docker/resources/routes/ad825f6c-1ae9-4f76-abc4-df2866b14b78 b/docker/resources/routes/ad825f6c-1ae9-4f76-abc4-df2866b14b78 new file mode 100644 index 000000000..12475d6cb --- /dev/null +++ b/docker/resources/routes/ad825f6c-1ae9-4f76-abc4-df2866b14b78 @@ -0,0 +1 @@ +{"distance":18912,"name":"test route","description":"testing route stuff","feature":{"type":"Feature","geometry":{"type":"LineString","coordinates":[[23.421658428594455,59.976383142599445],[23.39545298552773,59.964698713370666],[23.386547033272887,59.94553321282956],[23.349311506736232,59.92852692137802],[23.352379069279134,59.912782827217114],[23.420858546854152,59.91443887159909],[23.529026801965298,59.9327648091369]]},"properties":{},"id":""}} \ No newline at end of file diff --git a/docker/resources/routes/da825f6c-1ae9-4f76-abc4-df2866b14b78 b/docker/resources/routes/da825f6c-1ae9-4f76-abc4-df2866b14b78 new file mode 100644 index 000000000..12475d6cb --- /dev/null +++ b/docker/resources/routes/da825f6c-1ae9-4f76-abc4-df2866b14b78 @@ -0,0 +1 @@ +{"distance":18912,"name":"test route","description":"testing route stuff","feature":{"type":"Feature","geometry":{"type":"LineString","coordinates":[[23.421658428594455,59.976383142599445],[23.39545298552773,59.964698713370666],[23.386547033272887,59.94553321282956],[23.349311506736232,59.92852692137802],[23.352379069279134,59.912782827217114],[23.420858546854152,59.91443887159909],[23.529026801965298,59.9327648091369]]},"properties":{},"id":""}} \ No newline at end of file diff --git a/docker/resources/waypoints/ac3a3b2d-07e8-4f25-92bc-98e7c92f7f1a b/docker/resources/waypoints/ac3a3b2d-07e8-4f25-92bc-98e7c92f7f1a new file mode 100644 index 000000000..da4aa98d3 --- /dev/null +++ b/docker/resources/waypoints/ac3a3b2d-07e8-4f25-92bc-98e7c92f7f1a @@ -0,0 +1,17 @@ +{ + "name": "demo waypoint", + "description": "", + "feature": { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 23.455311064598344, + 59.99716209068623 + ] + }, + "properties": {}, + "id": "" + } + } + diff --git a/docker/resources/waypoints/afe46290-aa98-4d2f-9c04-d199ca64942e b/docker/resources/waypoints/afe46290-aa98-4d2f-9c04-d199ca64942e new file mode 100644 index 000000000..a1587b015 --- /dev/null +++ b/docker/resources/waypoints/afe46290-aa98-4d2f-9c04-d199ca64942e @@ -0,0 +1,18 @@ +{ + "name": "lock", + "description": "this is the lock", + "feature": { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 23.435321561218167, + 59.98480312764812 + ] + }, + "properties": {}, + "id": "" + }, + "timestamp": "2022-04-21T18:23:19.815Z", + "$source": "resources-provider" + } \ No newline at end of file diff --git a/docker/serverstate/course/settings.json b/docker/serverstate/course/settings.json new file mode 100644 index 000000000..f3e9d0ea6 --- /dev/null +++ b/docker/serverstate/course/settings.json @@ -0,0 +1 @@ +{"activeRoute":{"href":"/resources/routes/urn:mrn:signalk:uuid:ad825f6c-1ae9-4f76-abc4-df2866b14b78","startTime":"2022-04-21T18:40:44.319Z","pointIndex":3,"pointTotal":7,"reverse":true},"nextPoint":{"href":null,"type":"RoutePoint","position":{"latitude":59.92852692137802,"longitude":23.349311506736232},"arrivalCircle":500},"previousPoint":{"href":null,"type":"RoutePoint","position":{"longitude":23.485033333333334,"latitude":60.033516666666664}}} \ No newline at end of file From 32dac268537602f42eb9e3fd4405b0a254ec59d6 Mon Sep 17 00:00:00 2001 From: Teppo Kurki Date: Wed, 27 Apr 2022 19:17:01 +0300 Subject: [PATCH 30/63] feature: add support for forcing https in openapi url --- README.md | 1 + src/api/swagger.ts | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 59f370f81..b801e6d01 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,7 @@ Environment variables - `SIGNALK_NODE_CONFIG_DIR` override the path to find server configuration files. - `PORT` override the port for http/ws service (default is 3000). - `SSLPORT` override the port for https/wss service. If defined activates ssl as forced, default protocol (default is 3443). +- `PROTOCOL` override http/https where the server is accessed via https but the server sees http (for example when Heroku handles https termination) - `EXTERNALPORT` the port used in /signalk response and Bonjour advertisement. Has precedence over configuration file. - `EXTERNALHOST` the host used in /signalk response and Bonjour advertisement. Has precedence over configuration file. - `FILEUPLOADSIZELIMIT` override the file upload size limit (default is '10mb'). diff --git a/src/api/swagger.ts b/src/api/swagger.ts index 066eeece1..b11a16a2c 100644 --- a/src/api/swagger.ts +++ b/src/api/swagger.ts @@ -40,9 +40,9 @@ export function mountSwaggerUi(app: any, path: string) { if (apiDocs[req.params.api]) { apiDocs[req.params.api].apiDoc.servers = [ { - url: `${req.protocol}://${req.get('Host')}${ - apiDocs[req.params.api].path - }` + url: `${process.env.PROTOCOL ? 'https' : req.protocol}://${req.get( + 'Host' + )}${apiDocs[req.params.api].path}` } ] res.json(apiDocs[req.params.api].apiDoc) From 825a92c5bf06612394fc84117a51842fe6d4b4e1 Mon Sep 17 00:00:00 2001 From: Teppo Kurki Date: Wed, 27 Apr 2022 21:10:38 +0300 Subject: [PATCH 31/63] Heroku build command as oneliner --- docker/Dockerfile_heroku_api_demo | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docker/Dockerfile_heroku_api_demo b/docker/Dockerfile_heroku_api_demo index 9c32405d3..d43fee020 100644 --- a/docker/Dockerfile_heroku_api_demo +++ b/docker/Dockerfile_heroku_api_demo @@ -1,6 +1,6 @@ -#docker buildx build --platform linux/amd64 -f Dockerfile_heroku_api_demo -t registry.heroku.com/signalk-course-resources-api/web . -#docker push registry.heroku.com/signalk-course-resources-api/web -#heroku container:release web -a signalk-course-resources-api +# docker buildx build --platform linux/amd64 -f Dockerfile_heroku_api_demo -t registry.heroku.com/signalk-course-resources-api/web . && \ +# docker push registry.heroku.com/signalk-course-resources-api/web && \ +# heroku container:release web -a signalk-course-resources-api FROM signalk/signalk-server:resources_course_api USER root From 726f0554cddb2c67be2bff52902f436655e7af35 Mon Sep 17 00:00:00 2001 From: panaaj <38519157+panaaj@users.noreply.github.com> Date: Thu, 28 Apr 2022 10:49:58 +0930 Subject: [PATCH 32/63] Set previousPoint to vessel position for routes. previousPoint was set to a route point position which was impacting XTE. --- src/api/course/index.ts | 56 +++++++++++++++-------------------------- 1 file changed, 20 insertions(+), 36 deletions(-) diff --git a/src/api/course/index.ts b/src/api/course/index.ts index 932757a88..5df0a06d9 100644 --- a/src/api/course/index.ts +++ b/src/api/course/index.ts @@ -386,29 +386,21 @@ export class CourseApi { this.courseInfo.nextPoint.href = null // set previousPoint - if (this.courseInfo.activeRoute.pointIndex === 0) { - try { - const position: any = this.getVesselPosition() - if (position && position.value) { - this.courseInfo.previousPoint.position = position.value - this.courseInfo.previousPoint.type = `VesselPosition` - } else { - res.status(400).json(Responses.invalid) - return false - } - } catch (err) { - console.log(`** Error: unable to retrieve vessel position!`) + try { + const position: any = this.getVesselPosition() + if (position && position.value) { + this.courseInfo.previousPoint.position = position.value + this.courseInfo.previousPoint.type = `VesselPosition` + } else { res.status(400).json(Responses.invalid) return false } - } else { - this.courseInfo.previousPoint.position = this.getRoutePoint( - rte, - (this.courseInfo.activeRoute.pointIndex as number) - 1, - this.courseInfo.activeRoute.reverse - ) - this.courseInfo.previousPoint.type = `RoutePoint` + } catch (err) { + console.log(`** Error: unable to retrieve vessel position!`) + res.status(400).json(Responses.invalid) + return false } + this.courseInfo.previousPoint.href = null this.emitCourseInfo() res.status(200).json(Responses.ok) @@ -470,26 +462,18 @@ export class CourseApi { newCourse.nextPoint.href = null // set previousPoint - if (newCourse.activeRoute.pointIndex === 0) { - try { - const position: any = this.getVesselPosition() - if (position && position.value) { - this.courseInfo.previousPoint.position = position.value - this.courseInfo.previousPoint.type = `VesselPosition` - } else { - throw new Error(`Error: Unable to retrieve vessel position!`) - } - } catch (err) { + try { + const position: any = this.getVesselPosition() + if (position && position.value) { + this.courseInfo.previousPoint.position = position.value + this.courseInfo.previousPoint.type = `VesselPosition` + } else { throw new Error(`Error: Unable to retrieve vessel position!`) } - } else { - newCourse.previousPoint.position = this.getRoutePoint( - rte, - newCourse.activeRoute.pointIndex - 1, - newCourse.activeRoute.reverse - ) - newCourse.previousPoint.type = `RoutePoint` + } catch (err) { + throw new Error(`Error: Unable to retrieve vessel position!`) } + newCourse.previousPoint.href = null this.courseInfo = newCourse From 0a2695a1c150664bedf90b877f874129eeeb5453 Mon Sep 17 00:00:00 2001 From: panaaj <38519157+panaaj@users.noreply.github.com> Date: Thu, 28 Apr 2022 16:48:57 +0930 Subject: [PATCH 33/63] Update calculations to calcValues in openapi --- src/api/course/openApi.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/api/course/openApi.json b/src/api/course/openApi.json index 1577b304b..316d67249 100644 --- a/src/api/course/openApi.json +++ b/src/api/course/openApi.json @@ -647,12 +647,12 @@ } } }, - "/course/calculations": { + "/course/calcValues": { "get": { "tags": [ "calculations" ], - "summary": "Course calculations", + "summary": "Course calculated values.", "description": "Returns the current course status", "responses": { "200": { From 86983e0573b89a42e52d842f9db739d46f481b4d Mon Sep 17 00:00:00 2001 From: panaaj <38519157+panaaj@users.noreply.github.com> Date: Thu, 28 Apr 2022 20:35:29 +0930 Subject: [PATCH 34/63] chore: beta.2 dependencies --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index f5313201a..8cffc292f 100644 --- a/package.json +++ b/package.json @@ -77,7 +77,7 @@ "packages/resources-provider-plugin" ], "dependencies": { - "@signalk/course-provider": "^1.0.0-beta.1", + "@signalk/course-provider": "^1.0.0-beta.2", "@signalk/n2k-signalk": "^2.0.0", "@signalk/nmea0183-signalk": "^3.0.0", "@signalk/resources-provider": "github:SignalK/resources-provider-plugin", @@ -135,7 +135,7 @@ "ws": "^7.0.0" }, "optionalDependencies": { - "@signalk/freeboard-sk": "^2.0.0-beta.1", + "@signalk/freeboard-sk": "^2.0.0-beta.2", "@signalk/instrumentpanel": "0.x", "@signalk/set-system-time": "^1.2.0", "@signalk/signalk-to-nmea0183": "^1.0.0", From 1be1cd8ca447d48b147741ed4ebfb2ded2266ec3 Mon Sep 17 00:00:00 2001 From: panaaj <38519157+panaaj@users.noreply.github.com> Date: Thu, 5 May 2022 17:24:09 +0930 Subject: [PATCH 35/63] fix couese.calcValues openapi definition --- src/api/course/openApi.json | 82 ++++++++++++++++++++++--------------- 1 file changed, 50 insertions(+), 32 deletions(-) diff --git a/src/api/course/openApi.json b/src/api/course/openApi.json index 316d67249..7e4119c79 100644 --- a/src/api/course/openApi.json +++ b/src/api/course/openApi.json @@ -152,39 +152,57 @@ "description": "The bearing of a line between previousPoint and nextPoint, relative to magnetic north. (angle in radians)", "example": 4.51234 }, - "estimatedTimeOfArrival": { - "type": "string", - "description": "The estimated time of arrival at nextPoint position.", - "example": "2019-10-02T18:36:12.123+01:00" - }, - "distance": { - "type": "number", - "minimum": 0, - "description": "The distance in meters between the vessel's present position and the nextPoint.", - "example": 10157 - }, - "bearingTrue": { - "type": "number", - "minimum": 0, - "description": "The bearing of a line between the vessel's current position and nextPoint, relative to true north. (angle in radians)", - "example": 4.58491 - }, - "bearingMagnetic": { - "type": "number", - "minimum": 0, - "description": "The bearing of a line between the vessel's current position and nextPoint, relative to magnetic north. (angle in radians)", - "example": 4.51234 - }, - "velocityMadeGood": { - "type": "number", - "description": "The velocity component of the vessel towards the nextPoint in m/s", - "example": 7.2653 + "nextPoint": { + "type": "object", + "description": "Calculations relative to Destination position", + "properties": { + "estimatedTimeOfArrival": { + "type": "string", + "description": "The estimated time of arrival at nextPoint position.", + "example": "2019-10-02T18:36:12.123+01:00" + }, + "distance": { + "type": "number", + "minimum": 0, + "description": "The distance in meters between the vessel's present position and the nextPoint.", + "example": 10157 + }, + "bearingTrue": { + "type": "number", + "minimum": 0, + "description": "The bearing of a line between the vessel's current position and nextPoint, relative to true north. (angle in radians)", + "example": 4.58491 + }, + "bearingMagnetic": { + "type": "number", + "minimum": 0, + "description": "The bearing of a line between the vessel's current position and nextPoint, relative to magnetic north. (angle in radians)", + "example": 4.51234 + }, + "velocityMadeGood": { + "type": "number", + "description": "The velocity component of the vessel towards the nextPoint in m/s", + "example": 7.2653 + }, + "timeToGo": { + "type": "number", + "minimum": 0, + "description": "Time in seconds to reach nextPoint's perpendicular with current speed & direction.", + "example": 8491 + } + } }, - "timeToGo": { - "type": "number", - "minimum": 0, - "description": "Time in seconds to reach nextPoint's perpendicular with current speed & direction.", - "example": 8491 + "previousPoint": { + "type": "object", + "description": "Calculations relative to source position.", + "properties": { + "distance": { + "type": "number", + "minimum": 0, + "description": "The distance in meters between the vessel's present position and the start point.", + "example": 10157 + } + } } } } From dd887078f7b8299f439684325645bed8ce0a3693 Mon Sep 17 00:00:00 2001 From: panaaj <38519157+panaaj@users.noreply.github.com> Date: Fri, 6 May 2022 10:08:41 +0930 Subject: [PATCH 36/63] chore: update beta plugin dependencies --- package.json | 4 +-- src/api/course/openApi.json | 72 +++++++++++++++++-------------------- 2 files changed, 35 insertions(+), 41 deletions(-) diff --git a/package.json b/package.json index 8cffc292f..d382063e2 100644 --- a/package.json +++ b/package.json @@ -77,7 +77,7 @@ "packages/resources-provider-plugin" ], "dependencies": { - "@signalk/course-provider": "^1.0.0-beta.2", + "@signalk/course-provider": "^1.0.0-beta.3", "@signalk/n2k-signalk": "^2.0.0", "@signalk/nmea0183-signalk": "^3.0.0", "@signalk/resources-provider": "github:SignalK/resources-provider-plugin", @@ -135,7 +135,7 @@ "ws": "^7.0.0" }, "optionalDependencies": { - "@signalk/freeboard-sk": "^2.0.0-beta.2", + "@signalk/freeboard-sk": "^2.0.0-beta.3", "@signalk/instrumentpanel": "0.x", "@signalk/set-system-time": "^1.2.0", "@signalk/signalk-to-nmea0183": "^1.0.0", diff --git a/src/api/course/openApi.json b/src/api/course/openApi.json index 7e4119c79..ef6ad7144 100644 --- a/src/api/course/openApi.json +++ b/src/api/course/openApi.json @@ -152,45 +152,39 @@ "description": "The bearing of a line between previousPoint and nextPoint, relative to magnetic north. (angle in radians)", "example": 4.51234 }, - "nextPoint": { - "type": "object", - "description": "Calculations relative to Destination position", - "properties": { - "estimatedTimeOfArrival": { - "type": "string", - "description": "The estimated time of arrival at nextPoint position.", - "example": "2019-10-02T18:36:12.123+01:00" - }, - "distance": { - "type": "number", - "minimum": 0, - "description": "The distance in meters between the vessel's present position and the nextPoint.", - "example": 10157 - }, - "bearingTrue": { - "type": "number", - "minimum": 0, - "description": "The bearing of a line between the vessel's current position and nextPoint, relative to true north. (angle in radians)", - "example": 4.58491 - }, - "bearingMagnetic": { - "type": "number", - "minimum": 0, - "description": "The bearing of a line between the vessel's current position and nextPoint, relative to magnetic north. (angle in radians)", - "example": 4.51234 - }, - "velocityMadeGood": { - "type": "number", - "description": "The velocity component of the vessel towards the nextPoint in m/s", - "example": 7.2653 - }, - "timeToGo": { - "type": "number", - "minimum": 0, - "description": "Time in seconds to reach nextPoint's perpendicular with current speed & direction.", - "example": 8491 - } - } + "estimatedTimeOfArrival": { + "type": "string", + "description": "The estimated time of arrival at nextPoint position.", + "example": "2019-10-02T18:36:12.123+01:00" + }, + "distance": { + "type": "number", + "minimum": 0, + "description": "The distance in meters between the vessel's present position and the nextPoint.", + "example": 10157 + }, + "bearingTrue": { + "type": "number", + "minimum": 0, + "description": "The bearing of a line between the vessel's current position and nextPoint, relative to true north. (angle in radians)", + "example": 4.58491 + }, + "bearingMagnetic": { + "type": "number", + "minimum": 0, + "description": "The bearing of a line between the vessel's current position and nextPoint, relative to magnetic north. (angle in radians)", + "example": 4.51234 + }, + "velocityMadeGood": { + "type": "number", + "description": "The velocity component of the vessel towards the nextPoint in m/s", + "example": 7.2653 + }, + "timeToGo": { + "type": "number", + "minimum": 0, + "description": "Time in seconds to reach nextPoint's perpendicular with current speed & direction.", + "example": 8491 }, "previousPoint": { "type": "object", From aa5d31b20c7913d9e7aadb9a2ed7a3b537a46af8 Mon Sep 17 00:00:00 2001 From: panaaj <38519157+panaaj@users.noreply.github.com> Date: Wed, 18 May 2022 11:42:03 +0930 Subject: [PATCH 37/63] OpenAPI: add pattern for timestamp values --- src/api/course/openApi.json | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/api/course/openApi.json b/src/api/course/openApi.json index ef6ad7144..280948eb1 100644 --- a/src/api/course/openApi.json +++ b/src/api/course/openApi.json @@ -38,6 +38,12 @@ ], "components": { "schemas": { + + "IsoTime": { + "type": "string", + "pattern": "^(\\d{4})-(\\d{2})-(\\d{2})T(\\d{2}):(\\d{2}):(\\d{2}(?:\\.\\d*)?)((-(\\d{2}):(\\d{2})|Z)?)$", + "example": "2022-04-22T05:02:56.484Z" + }, "SignalKHrefRoute": { "type": "string", "pattern": "^\/resources\/routes\/urn:mrn:signalk:uuid:[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-4[0-9A-Fa-f]{3}-[89ABab][0-9A-Fa-f]{3}-[0-9A-Fa-f]{12}$", @@ -153,9 +159,8 @@ "example": 4.51234 }, "estimatedTimeOfArrival": { - "type": "string", - "description": "The estimated time of arrival at nextPoint position.", - "example": "2019-10-02T18:36:12.123+01:00" + "$ref": "#/components/schemas/IsoTime", + "description": "The estimated time of arrival at nextPoint position." }, "distance": { "type": "number", @@ -278,7 +283,7 @@ ], "properties": { "startTime": { - "type": "string", + "$ref": "#/components/schemas/IsoTime", "example": "2022-04-22T05:02:56.484Z" }, "activeRoute": { From be39cbe9ddcf72f666a46393a5b455d27293701d Mon Sep 17 00:00:00 2001 From: panaaj <38519157+panaaj@users.noreply.github.com> Date: Sun, 22 May 2022 11:37:54 +0930 Subject: [PATCH 38/63] Add OpenAPI definitions for notifications. Special notifications outlined in specification Notifications used by apps/plugins included in server distro. --- src/api/notifications/openApi.json | 359 +++++++++++++++++++++++++++++ src/api/notifications/openApi.ts | 7 + src/api/swagger.ts | 3 +- 3 files changed, 368 insertions(+), 1 deletion(-) create mode 100644 src/api/notifications/openApi.json create mode 100644 src/api/notifications/openApi.ts diff --git a/src/api/notifications/openApi.json b/src/api/notifications/openApi.json new file mode 100644 index 000000000..328c18cc4 --- /dev/null +++ b/src/api/notifications/openApi.json @@ -0,0 +1,359 @@ +{ + "openapi": "3.0.0", + "info": { + "version": "2.0.0", + "title": "Signal K Notifications API", + "termsOfService": "http://signalk.org/terms/", + "license": { + "name": "Apache 2.0", + "url": "http://www.apache.org/licenses/LICENSE-2.0.html" + } + }, + "externalDocs": { + "url": "http://signalk.org/specification/", + "description": "Signal K specification." + }, + "servers": [ + { + "url": "https://localhost:3000/signalk/v2/api/vessels/self/notifications" + } + ], + "tags": [ + { + "name": "special", + "description": "Special Alarms" + }, + { + "name": "course", + "description": "Course notifications" + }, + { + "name": "anchor", + "description": "Anchor watch notifications" + } + ], + "components": { + "schemas": { + "AlarmState": { + "type": "string", + "description": "Value describing the current state of the alarm.", + "example": "alert", + "enum": [ + "normal", + "nominal", + "alert", + "warning", + "alarm", + "emergency" + ] + }, + "AlarmMethod": { + "type": "array", + "minimum": 0, + "maximum": 2, + "uniqueItems": true, + "description": "Methods to use to raise the alarm.", + "example": ["sound"], + "items": { + "type": "string", + "enum": [ + "visual", + "sound" + ] + } + }, + "Alarm": { + "type": "object", + "required": ["state", "method","message"], + "properties": { + "state": { + "$ref": "#/components/schemas/AlarmState" + }, + "method": { + "$ref": "#/components/schemas/AlarmMethod" + }, + "message": { + "type": "string" + } + } + }, + "Notification": { + "type": "object", + "required": ["value"], + "properties": { + "value": { + "$ref": "#/components/schemas/Alarm" + } + } + } + }, + "responses": { + "200Ok": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Notification" + } + } + } + }, + "ErrorResponse": { + "description": "Failed operation", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Request error response", + "properties": { + "state": { + "type": "string", + "enum": [ + "FAILED" + ] + }, + "statusCode": { + "type": "number", + "enum": [ + 404 + ] + }, + "message": { + "type": "string" + } + }, + "required": [ + "state", + "statusCode", + "message" + ] + } + } + } + } + } + }, + "paths": { + "/mob": { + "get": { + "tags": [ + "special" + ], + "summary": "Man overboard alarm.", + "description": "Alarm indicating person(s) overboard.", + "responses": { + "200": { + "$ref": "#/components/responses/200Ok" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + }, + "/fire": { + "get": { + "tags": [ + "special" + ], + "summary": "Fire onboard alarm.", + "description": "Alarm indicating there is a fire onboard.", + "responses": { + "200": { + "$ref": "#/components/responses/200Ok" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + }, + "/sinking": { + "get": { + "tags": [ + "special" + ], + "summary": "Sinking vessel alarm.", + "description": "Alarm indicating vessel is sinking.", + "responses": { + "200": { + "$ref": "#/components/responses/200Ok" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + }, + "/flooding": { + "get": { + "tags": [ + "special" + ], + "summary": "Floodingalarm.", + "description": "Alarm indicating that veseel is taking on water.", + "responses": { + "200": { + "$ref": "#/components/responses/200Ok" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + }, + "/collision": { + "get": { + "tags": [ + "special" + ], + "summary": "Collision alarm.", + "description": "Alarm indicating vessel has been involved in a collision.", + "responses": { + "200": { + "$ref": "#/components/responses/200Ok" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + }, + "/grounding": { + "get": { + "tags": [ + "special" + ], + "summary": "Grounding alarm.", + "description": "Alarm indicating vessel has run aground.", + "responses": { + "200": { + "$ref": "#/components/responses/200Ok" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + }, + "/listing": { + "get": { + "tags": [ + "special" + ], + "summary": "Listing alarm.", + "description": "Alarm indicating vessel is listing beyond acceptable parameters.", + "responses": { + "200": { + "$ref": "#/components/responses/200Ok" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + }, + "/adrift": { + "get": { + "tags": [ + "special" + ], + "summary": "Adrift alarm.", + "description": "Alarm indicating that the vessel is set adrift.", + "responses": { + "200": { + "$ref": "#/components/responses/200Ok" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + }, + "/piracy": { + "get": { + "tags": [ + "special" + ], + "summary": "Piracy alarm.", + "description": "Alarm indicating pirates have been encountered / boarded.", + "responses": { + "200": { + "$ref": "#/components/responses/200Ok" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + }, + "/abandon": { + "get": { + "tags": [ + "special" + ], + "summary": "Abandon alarm.", + "description": "Alarm indicating vessel has been abandoned.", + "responses": { + "200": { + "$ref": "#/components/responses/200Ok" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + }, + "/navigation/course/arrivalCircleEntered": { + "get": { + "tags": [ + "course" + ], + "summary": "Arrival circle entered.", + "description": "Set when arrival circle around destination point has been entered.", + "responses": { + "200": { + "$ref": "#/components/responses/200Ok" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + }, + "/navigation/course/perpendicularPassed": { + "get": { + "tags": [ + "course" + ], + "summary": "Perpendicular passed.", + "description": "Set when line perpendicular to destination point has been passed by the vessel.", + "responses": { + "200": { + "$ref": "#/components/responses/200Ok" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + }, + "/anchor/maxRadius": { + "get": { + "tags": [ + "anchor" + ], + "summary": "Vessel outside anchor maximum radius.", + "description": "Set when the vessel position is outside the maxRadius value from the anchor.", + "responses": { + "200": { + "$ref": "#/components/responses/200Ok" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + } + } + } diff --git a/src/api/notifications/openApi.ts b/src/api/notifications/openApi.ts new file mode 100644 index 000000000..3ad1a8c61 --- /dev/null +++ b/src/api/notifications/openApi.ts @@ -0,0 +1,7 @@ +import notificationsApiDoc from './openApi.json' + +export const notificationsApiRecord = { + name: 'notifications', + path: '/signalk/v2/api/vessels/self/notifications', + apiDoc: notificationsApiDoc +} diff --git a/src/api/swagger.ts b/src/api/swagger.ts index b11a16a2c..94a6047f2 100644 --- a/src/api/swagger.ts +++ b/src/api/swagger.ts @@ -3,6 +3,7 @@ import swaggerUi from 'swagger-ui-express' import { SERVERROUTESPREFIX } from '../constants' import { courseApiRecord } from './course/openApi' import { resourcesApiRecord } from './resources/openApi' +import { notificationsApiRecord } from './notifications/openApi' interface OpenApiRecord { name: string @@ -12,7 +13,7 @@ interface OpenApiRecord { const apiDocs: { [name: string]: OpenApiRecord -} = [courseApiRecord, resourcesApiRecord].reduce( +} = [courseApiRecord, notificationsApiRecord, resourcesApiRecord].reduce( (acc: any, apiRecord: OpenApiRecord) => { acc[apiRecord.name] = apiRecord return acc From 3bbc79ec8598734f1ade2954a3d1c3e15987b0dc Mon Sep 17 00:00:00 2001 From: panaaj <38519157+panaaj@users.noreply.github.com> Date: Sun, 22 May 2022 11:39:29 +0930 Subject: [PATCH 39/63] Update server version to 2.0.0-beta To allow plugins to correctly test for server version. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d382063e2..cda70a3b9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "signalk-server", - "version": "1.42.0", + "version": "2.0.0-beta.1", "description": "An implementation of a [Signal K](http://signalk.org) server for boats.", "main": "index.js", "scripts": { From 005106bb9bc78d6bafbe8d371905e7707f0d9ae2 Mon Sep 17 00:00:00 2001 From: panaaj <38519157+panaaj@users.noreply.github.com> Date: Sat, 4 Jun 2022 14:21:43 +0930 Subject: [PATCH 40/63] chore: fix openAPI definitions --- src/api/notifications/openApi.json | 21 --- src/api/resources/openApi.json | 211 +++++++---------------------- 2 files changed, 47 insertions(+), 185 deletions(-) diff --git a/src/api/notifications/openApi.json b/src/api/notifications/openApi.json index 328c18cc4..1f6bbdb5a 100644 --- a/src/api/notifications/openApi.json +++ b/src/api/notifications/openApi.json @@ -26,10 +26,6 @@ { "name": "course", "description": "Course notifications" - }, - { - "name": "anchor", - "description": "Anchor watch notifications" } ], "components": { @@ -337,23 +333,6 @@ } } } - }, - "/anchor/maxRadius": { - "get": { - "tags": [ - "anchor" - ], - "summary": "Vessel outside anchor maximum radius.", - "description": "Set when the vessel position is outside the maxRadius value from the anchor.", - "responses": { - "200": { - "$ref": "#/components/responses/200Ok" - }, - "default": { - "$ref": "#/components/responses/ErrorResponse" - } - } - } } } } diff --git a/src/api/resources/openApi.json b/src/api/resources/openApi.json index ca552ed5a..cee11ccc3 100644 --- a/src/api/resources/openApi.json +++ b/src/api/resources/openApi.json @@ -579,62 +579,22 @@ } ] }, - "TileLayerExtModel": { - "description": "When format='pbf' Lists the layers that appear in the vector tiles and the names and types of the attributes of features that appear in those layers.", - "type": "object", - "required": [ - "id", - "fields" - ], - "properties": { - "id": { - "type": "string", - "description": "Layer id." - }, - "fields": { - "type": "object", - "description": "A JSON object whose keys and values are the names and types of attributes available in this layer. ", - "additionalProperties": true - }, - "description": { - "type": "string", - "description": "Layer description." - }, - "minzoom": { - "type": "string", - "description": "The lowest zoom level whose tiles this layer appears in." - }, - "maxzoom": { - "type": "string", - "description": "he highest zoom level whose tiles this layer appears in." - } - } - }, "TileLayerSource": { - "description": "Tile layer metadata model", + "description": "Attributes to describe a chart resource.", "type": "object", "required": [ - "sourceType", - "tiles" + "type" ], "properties": { - "sourceType": { + "type": { "type": "string", - "description": "Source type of chart data.", + "description": "Source type of map data.", "enum": [ "tilelayer" ], "default": "tilelayer", "example": "tilelayer" }, - "tiles": { - "type": "array", - "description": "An array of chart tile endpoints {z}, {x} and {y}. The array MUST contain at least one endpoint.", - "items": { - "type": "string" - }, - "example": "http://localhost:3000/signalk/v2/api/resources/charts/islands/{z}/{x}/{y}.png" - }, "bounds": { "description": "The maximum extent of available chart tiles in the format left, bottom, right, top.", "type": "array", @@ -649,138 +609,55 @@ 173.9166560895481, -40.70659187633642 ] + }, + "format": { + "type": "string", + "description": "The file format of the tile data.", + "enum": ["jpg", "pbf", "png", "webp"], + "example": "png" }, - "minzoom": { + "maxzoom": { "type": "number", - "description": "An integer specifying the minimum zoom level.", - "example": 19, + "description": "An integer specifying the maximum zoom level. MUST be >= minzoom.", + "example": 27, "default": 0, "minimum": 0, "maximum": 30 }, - "maxzoom": { + "minzoom": { "type": "number", - "description": "An integer specifying the maximum zoom level. MUST be >= minzoom.", - "example": 27, + "description": "An integer specifying the minimum zoom level.", + "example": 19, "default": 0, "minimum": 0, "maximum": 30 }, - "format": { - "type": "string", - "description": "The file format of the tile data.", - "enum": ["pbf", "jpg", "png", "webp"], - "example": "png" - }, - "scheme": { - "type": "string", - "description": "Influences the y direction of the tile coordinates.", - "enum": ["xyz", "tms"], - "example": "xyz", - "default": "xyz" - }, - "tilejson": { - "type": "string", - "description": "A semver.org style version number describing the version of the TileJSON spec.", - "example": "2.2.0" - }, - "version": { - "type": "string", - "description": "A semver.org style version number defining the version of the chart content.", - "example": "1.0.0", - "default": "1.0.0" - }, - "attribution": { - "type": "string", - "description": "Contains an attribution to be displayed when the map is shown.", - "example": "OSM contributors", - "default": null - }, - "type": { - "type": "string", - "description": "layer type", - "enum": ["overlay", "baselayer"] - }, - "center": { - "description": "Center of chart expressed as [longitude, latitude, zoom].", - "type": "array", - "items": { - "type": "number" - }, - "minItems": 3, - "maxItems": 3, - "example": [ - 172.7499244562935, - -41.27498133450632, - 8 - ] - }, - "vector_layers": { - "type": "array", - "description": "When format='pbf' Lists the layers that appear in the vector tiles and the names and types of the attributes of features that appear in those layers.", - "items": { - "$ref": "#/components/schemas/TileLayerExtModel" - } + "scale": { + "type": "number", + "description": "Map scale", + "minimum": 1, + "default": 250000, + "example": 250000 } } }, - "WmsSourceModel": { - "description": "WMS / WMTS source model", + "MapServerSource": { + "description": "Decribes Map server source types.", "type": "object", "required": [ - "sourceType", - "url" + "type" ], "properties": { - "sourceType": { + "type": { "type": "string", - "description": "Source type of chart data.", + "description": "Source type of map data.", "enum": [ - "wmts", - "wms" + "tilejson", + "wms", + "wmts" ], - "default": "wmts", + "default": "wms", "example": "wms" - }, - "url": { - "type": "string", - "description": "URL to WMS / WMTS service", - "example": "http://mapserver.org/wmts" - }, - "layers": { - "type": "array", - "items": { - "type": "string" - }, - "description": "List of chart layers to display.", - "example": [ - "Restricted Areas", - "Fishing Zones" - ] - } - } - }, - "TileJsonSource": { - "description": "TileJSON source model", - "type": "object", - "required": [ - "sourceType", - "url" - ], - "properties": { - "sourceType": { - "type": "string", - "description": "Source type of chart data.", - "enum": [ - "tilejson" - ], - "default": "tilejson", - "example": "tilejson" - }, - "url": { - "type": "string", - "description": "URL to TileJSON file", - "example": "http://mapserver.org/mychart.json" } } }, @@ -808,12 +685,21 @@ "example": "Tasman Bay coastline", "default": null }, - "scale": { - "type": "number", - "description": "chart scale", - "minimum": 1, - "default": 250000, - "example": 250000 + "url": { + "type": "string", + "description": "URL to tile / map source.", + "example": "http://mapserver.org/wms/nz615" + }, + "layers": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of chart layer ids.", + "example": [ + "Restricted.Areas", + "Fishing-Zones" + ] } }, "oneOf": [ @@ -821,10 +707,7 @@ "$ref": "#/components/schemas/TileLayerSource" }, { - "$ref": "#/components/schemas/TileJsonSource" - }, - { - "$ref": "#/components/schemas/WmsSourceModel" + "$ref": "#/components/schemas/MapServerSource" } ] }, From 2df9fd209ab6d12e181fd83d75a3da292faed027 Mon Sep 17 00:00:00 2001 From: panaaj <38519157+panaaj@users.noreply.github.com> Date: Fri, 10 Jun 2022 11:13:57 +0930 Subject: [PATCH 41/63] add targetArrivalTime and targetSpeed attributes to course definition --- src/api/course/openApi.json | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/api/course/openApi.json b/src/api/course/openApi.json index 280948eb1..9dc45ebfd 100644 --- a/src/api/course/openApi.json +++ b/src/api/course/openApi.json @@ -191,6 +191,11 @@ "description": "Time in seconds to reach nextPoint's perpendicular with current speed & direction.", "example": 8491 }, + "targetSpeed": { + "type": "number", + "description": "The average velocity required to reach the destination at the value of targetArriavlTime in m/s", + "example": 2.2653 + }, "previousPoint": { "type": "object", "description": "Calculations relative to source position.", @@ -284,7 +289,13 @@ "properties": { "startTime": { "$ref": "#/components/schemas/IsoTime", - "example": "2022-04-22T05:02:56.484Z" + "example": "2022-04-22T05:02:56.484Z", + "description": "Time at which navigation to destination commenced." + }, + "targetArrivalTime": { + "$ref": "#/components/schemas/IsoTime", + "example": "2022-04-22T05:02:56.484Z", + "description": "The desired time at which to arrive at the destination." }, "activeRoute": { "type": "object", From f716ac274c31646da8e1cdcb85b288ffc1b09aa1 Mon Sep 17 00:00:00 2001 From: panaaj <38519157+panaaj@users.noreply.github.com> Date: Sun, 12 Jun 2022 14:32:21 +0930 Subject: [PATCH 42/63] show webapp icons in admin-ui --- .../server-admin-ui/src/views/Webapps/Webapp.js | 13 ++++++++++--- .../server-admin-ui/src/views/Webapps/Webapps.js | 5 +++-- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/packages/server-admin-ui/src/views/Webapps/Webapp.js b/packages/server-admin-ui/src/views/Webapps/Webapp.js index a334b083c..714aedbb4 100644 --- a/packages/server-admin-ui/src/views/Webapps/Webapp.js +++ b/packages/server-admin-ui/src/views/Webapps/Webapp.js @@ -15,6 +15,7 @@ const propTypes = { children: PropTypes.node, className: PropTypes.string, cssModule: PropTypes.object, + bgImage: PropTypes.string } const defaultProps = { @@ -24,6 +25,7 @@ const defaultProps = { color: 'primary', variant: '0', link: '#', + bgImage: '' } class Widget02 extends Component { @@ -40,6 +42,7 @@ class Widget02 extends Component { link, children, variant, + bgImage, ...attributes } = this.props @@ -69,14 +72,18 @@ class Widget02 extends Component { 'text-capitalize' ) - const blockIcon = function (icon) { + const blockIcon = function (icon, bgImage = null) { const classes = classNames( icon, 'bg-' + card.color, padding.icon, 'font-2xl mr-3 float-left' ) - return + const style = { + backgroundSize: 'cover', + backgroundImage: bgImage ? `url(${bgImage})` : 'unset' + } + return } const cardFooter = function () { @@ -99,7 +106,7 @@ class Widget02 extends Component { - {blockIcon(card.icon)} + {blockIcon(card.icon, `${this.props.url}/${this.props.bgImage}`)}
{header}
{mainText}
diff --git a/packages/server-admin-ui/src/views/Webapps/Webapps.js b/packages/server-admin-ui/src/views/Webapps/Webapps.js index b43d7b360..6e8402313 100644 --- a/packages/server-admin-ui/src/views/Webapps/Webapps.js +++ b/packages/server-admin-ui/src/views/Webapps/Webapps.js @@ -56,11 +56,12 @@ class Webapps extends Component { ) From 9f204e2dda48501b99eda876243638f5a2808cce Mon Sep 17 00:00:00 2001 From: panaaj <38519157+panaaj@users.noreply.github.com> Date: Tue, 14 Jun 2022 09:40:13 +0930 Subject: [PATCH 43/63] chore: lint --- .../src/views/Webapps/Webapp.js | 8 ++++---- .../src/views/Webapps/Webapps.js | 18 +++++++++++++++--- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/packages/server-admin-ui/src/views/Webapps/Webapp.js b/packages/server-admin-ui/src/views/Webapps/Webapp.js index 714aedbb4..f7ebf308a 100644 --- a/packages/server-admin-ui/src/views/Webapps/Webapp.js +++ b/packages/server-admin-ui/src/views/Webapps/Webapp.js @@ -15,7 +15,7 @@ const propTypes = { children: PropTypes.node, className: PropTypes.string, cssModule: PropTypes.object, - bgImage: PropTypes.string + bgImage: PropTypes.string, } const defaultProps = { @@ -25,7 +25,7 @@ const defaultProps = { color: 'primary', variant: '0', link: '#', - bgImage: '' + bgImage: '', } class Widget02 extends Component { @@ -81,9 +81,9 @@ class Widget02 extends Component { ) const style = { backgroundSize: 'cover', - backgroundImage: bgImage ? `url(${bgImage})` : 'unset' + backgroundImage: bgImage ? `url(${bgImage})` : 'unset', } - return + return } const cardFooter = function () { diff --git a/packages/server-admin-ui/src/views/Webapps/Webapps.js b/packages/server-admin-ui/src/views/Webapps/Webapps.js index 6e8402313..44389a44f 100644 --- a/packages/server-admin-ui/src/views/Webapps/Webapps.js +++ b/packages/server-admin-ui/src/views/Webapps/Webapps.js @@ -56,12 +56,24 @@ class Webapps extends Component { ) From b43445ea5e9987f209040fece7e2ebcaa144585c Mon Sep 17 00:00:00 2001 From: panaaj <38519157+panaaj@users.noreply.github.com> Date: Tue, 14 Jun 2022 10:33:34 +0930 Subject: [PATCH 44/63] put handler for targetArrivalTime. --- src/api/course/index.ts | 43 +++++++++++++++++--- src/api/course/openApi.json | 79 ++++++++++++++++++++++++++----------- src/api/swagger.ts | 2 +- 3 files changed, 95 insertions(+), 29 deletions(-) diff --git a/src/api/course/index.ts b/src/api/course/index.ts index 5df0a06d9..ead8df98e 100644 --- a/src/api/course/index.ts +++ b/src/api/course/index.ts @@ -47,6 +47,7 @@ interface ActiveRoute extends DestinationBase { interface CourseInfo { startTime: string | null + targetArrivalTime: string | null activeRoute: { href: string | null pointIndex: number | null @@ -71,6 +72,7 @@ export class CourseApi { private courseInfo: CourseInfo = { startTime: null, + targetArrivalTime: null, activeRoute: { href: null, pointIndex: null, @@ -148,6 +150,24 @@ export class CourseApi { } ) + this.server.put( + `${COURSE_API_PATH}/arrivalCircle`, + async (req: Request, res: Response) => { + debug(`** PUT ${COURSE_API_PATH}/arrivalCircle`) + if (!this.updateAllowed(req)) { + res.status(403).json(Responses.unauthorised) + return + } + if (this.isValidArrivalCircle(req.body.value)) { + this.courseInfo.nextPoint.arrivalCircle = req.body.value + this.emitCourseInfo() + res.status(200).json(Responses.ok) + } else { + res.status(400).json(Responses.invalid) + } + } + ) + this.server.put( `${COURSE_API_PATH}/restart`, async (req: Request, res: Response) => { @@ -189,15 +209,15 @@ export class CourseApi { ) this.server.put( - `${COURSE_API_PATH}/arrivalCircle`, + `${COURSE_API_PATH}/targetArrivalTime`, async (req: Request, res: Response) => { - debug(`** PUT ${COURSE_API_PATH}/arrivalCircle`) + debug(`** PUT ${COURSE_API_PATH}/targetArrivalTime`) if (!this.updateAllowed(req)) { res.status(403).json(Responses.unauthorised) return } - if (this.isValidArrivalCircle(req.body.value)) { - this.courseInfo.nextPoint.arrivalCircle = req.body.value + if (this.isValidIsoTime(req.body.value)) { + this.courseInfo.targetArrivalTime = req.body.value this.emitCourseInfo() res.status(200).json(Responses.ok) } else { @@ -282,7 +302,7 @@ export class CourseApi { } ) - // clear activeRoute /destination + // clear activeRoute this.server.delete( `${COURSE_API_PATH}/activeRoute`, async (req: Request, res: Response) => { @@ -559,6 +579,7 @@ export class CourseApi { private clearDestination() { this.courseInfo.startTime = null + this.courseInfo.targetArrivalTime = null this.courseInfo.activeRoute.href = null this.courseInfo.activeRoute.pointIndex = null this.courseInfo.activeRoute.pointTotal = null @@ -575,6 +596,14 @@ export class CourseApi { return typeof value === 'number' && value >= 0 } + private isValidIsoTime(value: string | undefined): boolean { + + return !value + ? false + : /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2}(?:\.\d*)?)((-(\d{2}):(\d{2})|Z))$/ + .test(value) + } + private parsePointIndex(index: number, rte: any): number { if (typeof index !== 'number' || !rte) { return 0 @@ -653,6 +682,10 @@ export class CourseApi { path: `${navPath}.startTime`, value: this.courseInfo.startTime }) + values.push({ + path: `${navPath}.targetArrivalTime`, + value: this.courseInfo.targetArrivalTime + }) values.push({ path: `${navPath}.activeRoute.href`, value: this.courseInfo.activeRoute.href diff --git a/src/api/course/openApi.json b/src/api/course/openApi.json index 9dc45ebfd..9aec706e0 100644 --- a/src/api/course/openApi.json +++ b/src/api/course/openApi.json @@ -365,8 +365,8 @@ "tags": [ "course" ], - "summary": "Retrieve current course details", - "description": "Returns the current course status", + "summary": "Retrieve current course details.", + "description": "Returns the current course status.", "responses": { "200": { "$ref": "#/components/responses/CourseResponse" @@ -377,12 +377,45 @@ } } }, + "/course/arrivalCircle": { + "put": { + "tags": [ + "course" + ], + "summary": "Set arrival zone size.", + "description": "Sets the radius of a circle in meters centered at the current destination.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["value"], + "properties": { + "value": { + "$ref": "#/components/schemas/ArrivalCircle" + } + } + } + } + } + }, + "responses": { + "200": { + "$ref": "#/components/responses/200Ok" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + }, "/course/restart": { "put": { "tags": [ "course" ], - "summary": "Restart course calculations", + "summary": "Restart course calculations.", "description": "Sets previousPoint value to current vessel position and bases calculations on update.", "responses": { "200": { @@ -394,13 +427,13 @@ } } }, - "/course/arrivalCircle": { + "/course/targetArrivalTime": { "put": { "tags": [ "course" ], - "summary": "Set arrival zone size", - "description": "Sets the radius of a circle in meters centered at the current destination.", + "summary": "Set target arrival time.", + "description": "Sets the desired time to arrive at the destination. Used to calculate targetSpeed.", "requestBody": { "required": true, "content": { @@ -410,7 +443,7 @@ "required": ["value"], "properties": { "value": { - "$ref": "#/components/schemas/ArrivalCircle" + "$ref": "#/components/schemas/IsoTime" } } } @@ -432,10 +465,10 @@ "tags": [ "destination" ], - "summary": "Set destination", - "description": "Sets nextPoint path with supplied details", + "summary": "Set destination.", + "description": "Sets nextPoint path with supplied details.", "requestBody": { - "description": "destination details", + "description": "Destination details.", "required": true, "content": { "application/json": { @@ -470,8 +503,8 @@ "tags": [ "destination" ], - "summary": "Clear destination", - "description": "Clears all course information", + "summary": "Clear destination.", + "description": "Clears all course information.", "responses": { "200": { "$ref": "#/components/responses/200Ok" @@ -487,10 +520,10 @@ "tags": [ "activeRoute" ], - "summary": "Set active route", - "description": "Sets activeRoute path and sets nextPoint to first point in the route", + "summary": "Set active route.", + "description": "Sets activeRoute path and sets nextPoint to first point in the route.", "requestBody": { - "description": "Route to activate", + "description": "Route to activate.", "required": true, "content": { "application/json": { @@ -535,8 +568,8 @@ "tags": [ "activeRoute" ], - "summary": "Clear active route", - "description": "Clears all course information", + "summary": "Clear active route.", + "description": "Clears all course information.", "responses": { "200": { "$ref": "#/components/responses/200Ok" @@ -552,10 +585,10 @@ "tags": [ "activeRoute" ], - "summary": "Set next point in route", - "description": "Sets nextPoint / previousPoint", + "summary": "Set next point in route.", + "description": "Sets nextPoint / previousPoint.", "requestBody": { - "description": "destination details", + "description": "Destination details.", "required": true, "content": { "application/json": { @@ -663,7 +696,7 @@ "tags": [ "activeRoute" ], - "summary": "Refresh course information", + "summary": "Refresh course information.", "description": "Refresh course values after a change has been made.", "responses": { "200": { @@ -681,10 +714,10 @@ "calculations" ], "summary": "Course calculated values.", - "description": "Returns the current course status", + "description": "Returns the current course status.", "responses": { "200": { - "description": "Course data", + "description": "Course data.", "content": { "application/json": { "schema": { diff --git a/src/api/swagger.ts b/src/api/swagger.ts index 94a6047f2..d8f5379e4 100644 --- a/src/api/swagger.ts +++ b/src/api/swagger.ts @@ -2,8 +2,8 @@ import { NextFunction, Request, Response } from 'express' import swaggerUi from 'swagger-ui-express' import { SERVERROUTESPREFIX } from '../constants' import { courseApiRecord } from './course/openApi' -import { resourcesApiRecord } from './resources/openApi' import { notificationsApiRecord } from './notifications/openApi' +import { resourcesApiRecord } from './resources/openApi' interface OpenApiRecord { name: string From f11f0965037e492b930888a9849ee3e73e3e1742 Mon Sep 17 00:00:00 2001 From: panaaj <38519157+panaaj@users.noreply.github.com> Date: Tue, 14 Jun 2022 10:35:29 +0930 Subject: [PATCH 45/63] chore: lint --- src/api/course/index.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/api/course/index.ts b/src/api/course/index.ts index ead8df98e..e951e2dfe 100644 --- a/src/api/course/index.ts +++ b/src/api/course/index.ts @@ -597,11 +597,11 @@ export class CourseApi { } private isValidIsoTime(value: string | undefined): boolean { - return !value ? false - : /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2}(?:\.\d*)?)((-(\d{2}):(\d{2})|Z))$/ - .test(value) + : /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2}(?:\.\d*)?)((-(\d{2}):(\d{2})|Z))$/.test( + value + ) } private parsePointIndex(index: number, rte: any): number { From 43bb770a0e2a90ff6ea049127cd9d8f8764b20fd Mon Sep 17 00:00:00 2001 From: panaaj <38519157+panaaj@users.noreply.github.com> Date: Fri, 17 Jun 2022 09:43:54 +0930 Subject: [PATCH 46/63] allow setting targetArrivalTime to null --- src/api/course/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/api/course/index.ts b/src/api/course/index.ts index e951e2dfe..74162fba4 100644 --- a/src/api/course/index.ts +++ b/src/api/course/index.ts @@ -216,7 +216,7 @@ export class CourseApi { res.status(403).json(Responses.unauthorised) return } - if (this.isValidIsoTime(req.body.value)) { + if (req.body.value === null || this.isValidIsoTime(req.body.value)) { this.courseInfo.targetArrivalTime = req.body.value this.emitCourseInfo() res.status(200).json(Responses.ok) From 384c228e89079fad07787436bb695ceb138b2f0c Mon Sep 17 00:00:00 2001 From: panaaj <38519157+panaaj@users.noreply.github.com> Date: Fri, 17 Jun 2022 09:44:02 +0930 Subject: [PATCH 47/63] chore: lint --- src/modules.test.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/modules.test.js b/src/modules.test.js index 28af791fd..b0dc425dc 100644 --- a/src/modules.test.js +++ b/src/modules.test.js @@ -11,8 +11,8 @@ const { describe('modulesWithKeyword', () => { it('returns a list of modules with one "installed" update in config dir', () => { const expectedModules = [ - '@signalk/freeboard-sk', - '@signalk/instrumentpanel' + '@signalk/instrumentpanel', + '@signalk/freeboard-sk' ] const updateInstalledModule = '@signalk/instrumentpanel' const indexOfInstalledModule = expectedModules.indexOf( From a126b96f5329e4a07b10f5c73d137fe9d7b43540 Mon Sep 17 00:00:00 2001 From: panaaj <38519157+panaaj@users.noreply.github.com> Date: Thu, 23 Jun 2022 15:55:54 +0930 Subject: [PATCH 48/63] chore: fix typo --- src/api/resources/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/api/resources/index.ts b/src/api/resources/index.ts index eda73d89b..b4cf2be1a 100644 --- a/src/api/resources/index.ts +++ b/src/api/resources/index.ts @@ -63,7 +63,7 @@ export class ResourcesApi { } debug(this.resProvider[provider.type]) } else { - const msg = `Error: ${provider?.type} alreaady registered!` + const msg = `Error: ${provider?.type} already registered!` debug(msg) throw new Error(msg) } From 7444e5455c8e4fc93417dce01535ef22ee68170c Mon Sep 17 00:00:00 2001 From: panaaj <38519157+panaaj@users.noreply.github.com> Date: Mon, 27 Jun 2022 16:53:58 +0930 Subject: [PATCH 49/63] add support for PGN129285 Ability to represent route details originating from external device --- packages/server-api/src/resourcetypes.ts | 8 ++--- src/api/course/index.ts | 40 ++++++++++++++++++++++-- src/api/course/openApi.json | 27 ++++++++++++++++ 3 files changed, 69 insertions(+), 6 deletions(-) diff --git a/packages/server-api/src/resourcetypes.ts b/packages/server-api/src/resourcetypes.ts index 0f0441958..ce318a88e 100644 --- a/packages/server-api/src/resourcetypes.ts +++ b/packages/server-api/src/resourcetypes.ts @@ -61,10 +61,10 @@ export interface Chart { chartFormat: string } -type GeoJsonPoint = [number, number, number?] -type GeoJsonLinestring = GeoJsonPoint[] -type GeoJsonPolygon = GeoJsonLinestring[] -type GeoJsonMultiPolygon = GeoJsonPolygon[] +export type GeoJsonPoint = [number, number, number?] +export type GeoJsonLinestring = GeoJsonPoint[] +export type GeoJsonPolygon = GeoJsonLinestring[] +export type GeoJsonMultiPolygon = GeoJsonPolygon[] interface Polygon { type: 'Feature' diff --git a/src/api/course/index.ts b/src/api/course/index.ts index 74162fba4..2fc275cca 100644 --- a/src/api/course/index.ts +++ b/src/api/course/index.ts @@ -8,7 +8,7 @@ import path from 'path' import { WithConfig } from '../../app' import { WithSecurityStrategy } from '../../security' -import { Position, Route } from '@signalk/server-api' +import { Position, Route, GeoJsonPoint } from '@signalk/server-api' import { isValidCoordinate } from 'geolib' import { Responses } from '../' import { Store } from '../../serverstate/store' @@ -43,6 +43,11 @@ interface Destination extends DestinationBase { interface ActiveRoute extends DestinationBase { pointIndex?: number reverse?: boolean + name?: string +} + +interface Location extends Position { + name?: string } interface CourseInfo { @@ -53,6 +58,8 @@ interface CourseInfo { pointIndex: number | null pointTotal: number | null reverse: boolean | null + name: string | null + waypoints: any[] | null } nextPoint: { href: string | null @@ -77,7 +84,9 @@ export class CourseApi { href: null, pointIndex: null, pointTotal: null, - reverse: null + reverse: null, + name: null, + waypoints: null }, nextPoint: { href: null, @@ -460,6 +469,9 @@ export class CourseApi { newCourse.startTime = new Date().toISOString() + newCourse.activeRoute.name = rte.name + newCourse.activeRoute.waypoints = this.getRoutePoints(rte) + if (this.isValidArrivalCircle(route.arrivalCircle as number)) { newCourse.nextPoint.arrivalCircle = route.arrivalCircle as number } @@ -584,6 +596,8 @@ export class CourseApi { this.courseInfo.activeRoute.pointIndex = null this.courseInfo.activeRoute.pointTotal = null this.courseInfo.activeRoute.reverse = null + this.courseInfo.activeRoute.name = null + this.courseInfo.activeRoute.waypoints = null this.courseInfo.nextPoint.href = null this.courseInfo.nextPoint.type = null this.courseInfo.nextPoint.position = null @@ -657,6 +671,20 @@ export class CourseApi { return result } + private getRoutePoints(rte: any) { + const pts = rte.feature.geometry.coordinates.map( + (pt: GeoJsonPoint) => { + return { + position: { + latitude: pt[1], + longitude: pt[0] + } + } + } + ) + return pts + } + private async getRoute(href: string): Promise { const h = this.parseHref(href) if (h) { @@ -702,6 +730,14 @@ export class CourseApi { path: `${navPath}.activeRoute.reverse`, value: this.courseInfo.activeRoute.reverse }) + values.push({ + path: `${navPath}.activeRoute.name`, + value: this.courseInfo.activeRoute.name + }) + values.push({ + path: `${navPath}.activeRoute.waypoints`, + value: this.courseInfo.activeRoute.waypoints + }) values.push({ path: `${navPath}.nextPoint.href`, diff --git a/src/api/course/openApi.json b/src/api/course/openApi.json index 9aec706e0..d41a2ca34 100644 --- a/src/api/course/openApi.json +++ b/src/api/course/openApi.json @@ -111,6 +111,21 @@ } } }, + "Location": { + "type": "object", + "description": "Position with metadata.", + "required": ["position"], + "properties": { + "name": { + "type": "string", + "description": "Location name / identifier", + "example": "Wpt001" + }, + "position": { + "$ref": "#/components/schemas/SignalKPosition" + } + } + }, "PointTypeAttribute": { "type": "object", "properties": { @@ -309,6 +324,18 @@ "href": { "$ref": "#/components/schemas/SignalKHrefRoute" }, + "name": { + "type": "string", + "description": "Name of route.", + "example": "Here to eternity." + }, + "waypoints": { + "type": "array", + "description": "Array of points that make up the route.", + "items": { + "$ref": "#/components/schemas/Location" + } + }, "pointIndex": { "type": "number", "minimum": 0, From d5ab1b688e3b6612d330d651c4ad7f64c23b86af Mon Sep 17 00:00:00 2001 From: panaaj <38519157+panaaj@users.noreply.github.com> Date: Thu, 30 Jun 2022 15:50:22 +0930 Subject: [PATCH 50/63] update calcMethod values to align with n2k-signalk --- src/api/course/openApi.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/api/course/openApi.json b/src/api/course/openApi.json index d41a2ca34..cc312a88d 100644 --- a/src/api/course/openApi.json +++ b/src/api/course/openApi.json @@ -152,8 +152,8 @@ "calcMethod": { "type": "string", "description": "Calculation method by which values are derived.", - "enum": ["Great Circle", "Rhumbline"], - "default": "Great Circle", + "enum": ["GreatCircle", "Rhumbline"], + "default": "GreatCircle", "example": "Rhumbline" }, "crossTrackError": { From d072dc3db18b9628114ccdd7094a28ccc1abedbb Mon Sep 17 00:00:00 2001 From: panaaj <38519157+panaaj@users.noreply.github.com> Date: Mon, 4 Jul 2022 15:16:08 +0930 Subject: [PATCH 51/63] chore: add targetArrivalTime to course tests --- test/course.ts | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/test/course.ts b/test/course.ts index 8327aa00c..253f12459 100644 --- a/test/course.ts +++ b/test/course.ts @@ -82,11 +82,14 @@ describe('Course Api', () => { data.startTime.should.match(DATETIME_REGEX) delete data.startTime data.should.deep.equal({ + targetArrivalTime: null, activeRoute: { href: null, + name: null, pointIndex: null, pointTotal: null, - reverse: null + reverse: null, + waypoints: null }, nextPoint: { href: null, @@ -220,11 +223,14 @@ describe('Course Api', () => { data.startTime.should.match(DATETIME_REGEX) delete data.startTime data.should.deep.equal({ + targetArrivalTime: null, activeRoute: { href: null, + name: null, pointIndex: null, pointTotal: null, - reverse: null + reverse: null, + waypoints: null }, nextPoint: { href, @@ -297,11 +303,14 @@ describe('Course Api', () => { await selfGetJson('navigation/course').then(data => { data.should.deep.equal({ startTime: null, + targetArrivalTime: null, activeRoute: { href: null, + name: null, pointIndex: null, pointTotal: null, - reverse: null + reverse: null, + waypoints: null }, nextPoint: { href: null, @@ -413,6 +422,9 @@ describe('Course Api', () => { await selfGetJson('navigation/course').then(data => { delete data.startTime + delete data.targetArrivalTime + delete data.activeRoute.name + delete data.activeRoute.waypoints data.should.deep.equal({ activeRoute: { href, @@ -549,11 +561,14 @@ describe('Course Api', () => { await selfGetJson('navigation/course').then(data => { data.should.deep.equal({ startTime: null, + targetArrivalTime: null, activeRoute: { href: null, + name: null, pointIndex: null, pointTotal: null, - reverse: null + reverse: null, + waypoints: null }, nextPoint: { href: null, From b4e51cc8281175f3393dec1413f19961aeb75a75 Mon Sep 17 00:00:00 2001 From: panaaj <38519157+panaaj@users.noreply.github.com> Date: Wed, 13 Jul 2022 14:24:04 +0930 Subject: [PATCH 52/63] fix test for resource href value --- packages/resources-provider-plugin/src/lib/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/resources-provider-plugin/src/lib/utils.ts b/packages/resources-provider-plugin/src/lib/utils.ts index ca88b28b2..8ccabb9b6 100644 --- a/packages/resources-provider-plugin/src/lib/utils.ts +++ b/packages/resources-provider-plugin/src/lib/utils.ts @@ -72,7 +72,7 @@ export const passFilter = (res: any, type: string, params: any) => { if (params.href) { // ** check is attached to another resource // console.log(`filter related href: ${params.href}`); - if (typeof res.href === 'undefined') { + if (typeof res.href === 'undefined' || !res.href) { ok = ok && false } else { // deconstruct resource href value From 2cb607c4ee0c97cfdb86bb39b0439be012a693ea Mon Sep 17 00:00:00 2001 From: panaaj <38519157+panaaj@users.noreply.github.com> Date: Mon, 18 Jul 2022 12:00:58 +0930 Subject: [PATCH 53/63] add OpenApi definitions for authentication --- src/api/security/openApi.json | 344 ++++++++++++++++++++++++++++++++++ src/api/security/openApi.ts | 7 + src/api/swagger.ts | 3 +- 3 files changed, 353 insertions(+), 1 deletion(-) create mode 100644 src/api/security/openApi.json create mode 100644 src/api/security/openApi.ts diff --git a/src/api/security/openApi.json b/src/api/security/openApi.json new file mode 100644 index 000000000..da5fb9d84 --- /dev/null +++ b/src/api/security/openApi.json @@ -0,0 +1,344 @@ +{ + "openapi": "3.0.0", + "info": { + "version": "1.0.0", + "title": "Signal K Security API", + "termsOfService": "http://signalk.org/terms/", + "license": { + "name": "Apache 2.0", + "url": "http://www.apache.org/licenses/LICENSE-2.0.html" + } + }, + "externalDocs": { + "url": "http://signalk.org/specification/", + "description": "Signal K specification." + }, + "servers": [ + { + "url": "https://localhost:3000/signalk/v1/api" + } + ], + "tags": [ + { + "name": "authentication", + "description": "User authentication" + }, + { + "name": "access", + "description": "Device access" + } + ], + "components": { + "schemas": { + "IsoTime": { + "type": "string", + "pattern": "^(\\d{4})-(\\d{2})-(\\d{2})T(\\d{2}):(\\d{2}):(\\d{2}(?:\\.\\d*)?)((-(\\d{2}):(\\d{2})|Z)?)$", + "example": "2022-04-22T05:02:56.484Z" + }, + "RequestState": { + "type": "string", + "enum": ["PENDING","FAILED","COMPLETED"] + } + }, + "responses": { + "200Ok": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "state": { + "type": "string", + "enum": [ + "COMPLETED" + ] + }, + "statusCode": { + "type": "number", + "enum": [ + 200 + ] + } + }, + "required": [ + "state", + "statusCode" + ] + } + } + } + }, + "ErrorResponse": { + "description": "Failed operation", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Request error response", + "properties": { + "state": { + "type": "string", + "enum": [ + "FAILED" + ] + }, + "statusCode": { + "type": "number", + "enum": [ + 404 + ] + }, + "message": { + "type": "string" + } + }, + "required": [ + "state", + "statusCode", + "message" + ] + } + } + } + }, + "AccessRequestResponse": { + "description": "Request status", + "content": { + "application/json": { + "schema": { + "description": "Request response", + "type": "object", + "required": [ + "state" + ], + "properties": { + "state": { + "$ref": "#/components/schemas/RequestState", + "default": "PENDING", + "example": "PENDING", + "description": "Status of request." + }, + "href": { + "type": "string", + "example": "/signalk/v1/requests/358b5f32-76bf-4b33-8b23-10a330827185", + "description": "Path where the status of the request can be checked." + } + } + } + } + } + }, + "RequestStatusResponse": { + "description": "Request status", + "content": { + "application/json": { + "schema": { + "description": "Request response", + "type": "object", + "required": [ + "state" + ], + "properties": { + "state": { + "$ref": "#/components/schemas/RequestState", + "example": "COMPLETED", + "default": "COMPLETED", + "description": "Status of request." + }, + "statusCode": { + "type": "number", + "example": 200, + "description": "Response status code." + }, + "message": { + "type": "string", + "description": "Message relating to the result status.", + "example": "A device with clientId '1234-45653-343453' has already requested access." + }, + "accessRequest": { + "type": "object", + "required": ["permission"], + "description": "Access request result.", + "properties": { + "permission": { + "type:": "string", + "enum": ["DENIED", "APPROVED"], + "example": "APPROVED" + }, + "token": { + "type": "string", + "description": "Authentication token to be supplied with future requests." + }, + "expirationTime": { + "$ref": "#/components/schemas/IsoTime", + "description": "Token expiration time." + } + } + } + } + } + } + } + } + }, + "securitySchemes": { + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT" + }, + "cookieAuth": { + "type": "apiKey", + "in": "cookie", + "name": "JAUTHENTICATION" + } + } + }, + + "paths": { + "/access/requests": { + "post": { + "tags": [ + "access" + ], + "summary": "Device access request.", + "description": "Returns request status and href.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "clientId", + "description" + ], + "properties": { + "clientId": { + "type": "string", + "description": "Client identifier.", + "example": "1234-45653-343453" + }, + "description": { + "type": "string", + "description": "Description of device.", + "example": "humidity sensor" + } + } + } + } + } + }, + "responses": { + "200": { + "$ref": "#/components/responses/AccessRequestResponse" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + }, + "/requests/{id}": { + "get": { + "tags": [ + "access" + ], + "summary": "Check device access status.", + "description": "Returns the status of the supplied request id.", + "responses": { + "200": { + "$ref": "#/components/responses/RequestStatusResponse" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + }, + "/auth/login": { + "post": { + "tags": [ + "authentication" + ], + "summary": "Authenticate user.", + "description": "Authenticate to server using username and password.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "username", + "password" + ], + "properties": { + "username": { + "type": "string", + "description": "User to authenticate" + }, + "password": { + "type": "string", + "description": "Password for supplied username." + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Successful Authentication response.", + "content": { + "application/json": { + "schema": { + "description": "Login success result", + "type": "object", + "required": [ + "token" + ], + "properties": { + "token": { + "type": "string", + "description": "Authentication token to be supplied with future requests." + }, + "timeToLive": { + "type": "number", + "description": "Token validity time (seconds)." + } + } + } + } + } + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + }, + "/auth/logout": { + "post": { + "tags": [ + "authentication" + ], + "summary": "Log out user.", + "description": "Log out the user with the token suplied in the request header.", + "security": [ + "cookieAuth", + "bearerAuth" + ], + "responses": { + "200": { + "$ref": "#/components/responses/200Ok" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + } + } + } diff --git a/src/api/security/openApi.ts b/src/api/security/openApi.ts new file mode 100644 index 000000000..fb0fe66b5 --- /dev/null +++ b/src/api/security/openApi.ts @@ -0,0 +1,7 @@ +import securityApiDoc from './openApi.json' + +export const securityApiRecord = { + name: 'security', + path: '/signalk/v1/api', + apiDoc: securityApiDoc +} diff --git a/src/api/swagger.ts b/src/api/swagger.ts index d8f5379e4..525aa50e3 100644 --- a/src/api/swagger.ts +++ b/src/api/swagger.ts @@ -4,6 +4,7 @@ import { SERVERROUTESPREFIX } from '../constants' import { courseApiRecord } from './course/openApi' import { notificationsApiRecord } from './notifications/openApi' import { resourcesApiRecord } from './resources/openApi' +import { securityApiRecord } from './security/openApi' interface OpenApiRecord { name: string @@ -13,7 +14,7 @@ interface OpenApiRecord { const apiDocs: { [name: string]: OpenApiRecord -} = [courseApiRecord, notificationsApiRecord, resourcesApiRecord].reduce( +} = [courseApiRecord, notificationsApiRecord, resourcesApiRecord, securityApiRecord].reduce( (acc: any, apiRecord: OpenApiRecord) => { acc[apiRecord.name] = apiRecord return acc From 990ed303b3e8bf340b2ff3b2f9ae5d01200014dc Mon Sep 17 00:00:00 2001 From: panaaj <38519157+panaaj@users.noreply.github.com> Date: Mon, 18 Jul 2022 12:01:34 +0930 Subject: [PATCH 54/63] add securityScheme to course & resources defs. --- src/api/course/openApi.json | 16 ++++++++++++++++ src/api/resources/openApi.json | 16 ++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/src/api/course/openApi.json b/src/api/course/openApi.json index cc312a88d..5a0a4bbf0 100644 --- a/src/api/course/openApi.json +++ b/src/api/course/openApi.json @@ -384,8 +384,24 @@ } } } + }, + "securitySchemes": { + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT" + }, + "cookieAuth": { + "type": "apiKey", + "in": "cookie", + "name": "JAUTHENTICATION" + } } }, + "security": [ + {"cookieAuth": []}, + {"bearerAuth": []} + ], "paths": { "/course": { "get": { diff --git a/src/api/resources/openApi.json b/src/api/resources/openApi.json index cee11ccc3..2fbf8eac4 100644 --- a/src/api/resources/openApi.json +++ b/src/api/resources/openApi.json @@ -1004,8 +1004,24 @@ ] } } + }, + "securitySchemes": { + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT" + }, + "cookieAuth": { + "type": "apiKey", + "in": "cookie", + "name": "JAUTHENTICATION" + } } }, + "security": [ + {"cookieAuth": []}, + {"bearerAuth": []} + ], "paths": { "/resources": { "get": { From fbb9fae31bff1431c302becf4dccfbcf5331202c Mon Sep 17 00:00:00 2001 From: panaaj <38519157+panaaj@users.noreply.github.com> Date: Mon, 18 Jul 2022 15:01:42 +0930 Subject: [PATCH 55/63] add parameters definition --- src/api/security/openApi.json | 16 +++++++++++----- src/api/security/openApi.ts | 2 +- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/api/security/openApi.json b/src/api/security/openApi.json index da5fb9d84..60a6a4316 100644 --- a/src/api/security/openApi.json +++ b/src/api/security/openApi.json @@ -13,11 +13,6 @@ "url": "http://signalk.org/specification/", "description": "Signal K specification." }, - "servers": [ - { - "url": "https://localhost:3000/signalk/v1/api" - } - ], "tags": [ { "name": "authentication", @@ -241,6 +236,17 @@ } }, "/requests/{id}": { + "parameters": [ + { + "name": "id", + "in": "path", + "description": "request id", + "required": true, + "schema": { + "type": "string" + } + } + ], "get": { "tags": [ "access" diff --git a/src/api/security/openApi.ts b/src/api/security/openApi.ts index fb0fe66b5..edb1819dc 100644 --- a/src/api/security/openApi.ts +++ b/src/api/security/openApi.ts @@ -2,6 +2,6 @@ import securityApiDoc from './openApi.json' export const securityApiRecord = { name: 'security', - path: '/signalk/v1/api', + path: '/signalk/v1', apiDoc: securityApiDoc } From a7b78a09d5fe3f87531f07062b821c171959004a Mon Sep 17 00:00:00 2001 From: panaaj <38519157+panaaj@users.noreply.github.com> Date: Mon, 18 Jul 2022 15:03:58 +0930 Subject: [PATCH 56/63] remove servers section from definition. Base url is defined in opemapi.ts --- src/api/course/openApi.json | 5 ----- src/api/notifications/openApi.json | 5 ----- src/api/notifications/openApi.ts | 2 +- src/api/resources/openApi.json | 6 ------ 4 files changed, 1 insertion(+), 17 deletions(-) diff --git a/src/api/course/openApi.json b/src/api/course/openApi.json index 5a0a4bbf0..01b44f898 100644 --- a/src/api/course/openApi.json +++ b/src/api/course/openApi.json @@ -13,11 +13,6 @@ "url": "http://signalk.org/specification/", "description": "Signal K specification." }, - "servers": [ - { - "url": "https://localhost:3000/signalk/v2/api/vessels/self/navigation" - } - ], "tags": [ { "name": "course", diff --git a/src/api/notifications/openApi.json b/src/api/notifications/openApi.json index 1f6bbdb5a..9799a0dbd 100644 --- a/src/api/notifications/openApi.json +++ b/src/api/notifications/openApi.json @@ -13,11 +13,6 @@ "url": "http://signalk.org/specification/", "description": "Signal K specification." }, - "servers": [ - { - "url": "https://localhost:3000/signalk/v2/api/vessels/self/notifications" - } - ], "tags": [ { "name": "special", diff --git a/src/api/notifications/openApi.ts b/src/api/notifications/openApi.ts index 3ad1a8c61..df66a61ca 100644 --- a/src/api/notifications/openApi.ts +++ b/src/api/notifications/openApi.ts @@ -2,6 +2,6 @@ import notificationsApiDoc from './openApi.json' export const notificationsApiRecord = { name: 'notifications', - path: '/signalk/v2/api/vessels/self/notifications', + path: '/signalk/v1/api/vessels/self/notifications', apiDoc: notificationsApiDoc } diff --git a/src/api/resources/openApi.json b/src/api/resources/openApi.json index 2fbf8eac4..6352e9a21 100644 --- a/src/api/resources/openApi.json +++ b/src/api/resources/openApi.json @@ -13,12 +13,6 @@ "url": "http://signalk.org/specification/", "description": "Signal K specification." }, - "servers": [ - { - "description": "Signal K Server", - "url": "http://localhost:3000/signalk/v2/api" - } - ], "tags": [ { "name": "resources", From 3b03950263fffe3f5ee895f85a51b5363c6e5211 Mon Sep 17 00:00:00 2001 From: panaaj <38519157+panaaj@users.noreply.github.com> Date: Mon, 18 Jul 2022 15:04:09 +0930 Subject: [PATCH 57/63] chore: lint --- src/api/course/index.ts | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/src/api/course/index.ts b/src/api/course/index.ts index 2fc275cca..3d29625ec 100644 --- a/src/api/course/index.ts +++ b/src/api/course/index.ts @@ -8,7 +8,7 @@ import path from 'path' import { WithConfig } from '../../app' import { WithSecurityStrategy } from '../../security' -import { Position, Route, GeoJsonPoint } from '@signalk/server-api' +import { GeoJsonPoint, Position, Route } from '@signalk/server-api' import { isValidCoordinate } from 'geolib' import { Responses } from '../' import { Store } from '../../serverstate/store' @@ -672,16 +672,14 @@ export class CourseApi { } private getRoutePoints(rte: any) { - const pts = rte.feature.geometry.coordinates.map( - (pt: GeoJsonPoint) => { - return { - position: { - latitude: pt[1], - longitude: pt[0] - } + const pts = rte.feature.geometry.coordinates.map((pt: GeoJsonPoint) => { + return { + position: { + latitude: pt[1], + longitude: pt[0] } } - ) + }) return pts } From d213c5a9500dce905b4a68034937f79854338a09 Mon Sep 17 00:00:00 2001 From: panaaj <38519157+panaaj@users.noreply.github.com> Date: Mon, 18 Jul 2022 15:04:34 +0930 Subject: [PATCH 58/63] add Apps api definition --- src/api/apps/openApi.json | 249 ++++++++++++++++++++++++++++++++++++++ src/api/apps/openApi.ts | 7 ++ src/api/swagger.ts | 18 +-- 3 files changed, 267 insertions(+), 7 deletions(-) create mode 100644 src/api/apps/openApi.json create mode 100644 src/api/apps/openApi.ts diff --git a/src/api/apps/openApi.json b/src/api/apps/openApi.json new file mode 100644 index 000000000..2f1310a6d --- /dev/null +++ b/src/api/apps/openApi.json @@ -0,0 +1,249 @@ +{ + "openapi": "3.0.0", + "info": { + "version": "1.0.0", + "title": "Signal K Apps API", + "termsOfService": "http://signalk.org/terms/", + "license": { + "name": "Apache 2.0", + "url": "http://www.apache.org/licenses/LICENSE-2.0.html" + } + }, + "externalDocs": { + "url": "http://signalk.org/specification/", + "description": "Signal K specification." + }, + "tags": [ + { + "name": "server", + "description": "Server App / WebApp operations" + }, + { + "name": "repository", + "description": "Repository / AppStore operations" + } + ], + "components": { + "schemas": { + "AppEntry": { + "type": "object", + "required": [ + "name", + "description", + "location" + ], + "properties": { + "name": { + "type": "string", + "description": "Application name.", + "example": "@signalk/instrumentpanel" + }, + "version": { + "type": "string", + "description": "Application version.", + "example": "1.3.1" + }, + "description": { + "type": "string", + "description": "Application description.", + "example": "Signal K Instrument Panel" + }, + "location": { + "type": "string", + "description": "Path to application.", + "example": "/@signalk/instrumentpanel" + }, + "license": { + "type": "string", + "description": "Application license.", + "example": "Apache-2.0" + }, + "author": { + "type": "string", + "description": "Application author(s).", + "example": "author1@hotmail.com, author2@hotmail.com" + } + } + } + }, + "responses": { + "200Ok": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "state": { + "type": "string", + "enum": [ + "COMPLETED" + ] + }, + "statusCode": { + "type": "number", + "enum": [ + 200 + ] + } + }, + "required": [ + "state", + "statusCode" + ] + } + } + } + }, + "ErrorResponse": { + "description": "Failed operation", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Request error response", + "properties": { + "state": { + "type": "string", + "enum": [ + "FAILED" + ] + }, + "statusCode": { + "type": "number", + "enum": [ + 404 + ] + }, + "message": { + "type": "string" + } + }, + "required": [ + "state", + "statusCode", + "message" + ] + } + } + } + }, + "AppsListResponse": { + "description": "Application list response.", + "content": { + "application/json": { + "schema": { + "description": "Application list.", + "type": "array", + "items": { + "$ref": "#/components/schemas/AppEntry" + } + } + } + } + } + }, + "securitySchemes": { + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT" + }, + "cookieAuth": { + "type": "apiKey", + "in": "cookie", + "name": "JAUTHENTICATION" + } + } + }, + + "paths": { + "/signalk/v1/apps/list": { + "get": { + "tags": [ + "server" + ], + "summary": "WebApp list.", + "description": "Returns a list of all installed WebApps.", + "responses": { + "200": { + "$ref": "#/components/responses/AppsListResponse" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + }, + "/skServer/appstore/install/{pkgName}/{version}": { + "post": { + "tags": [ + "repository" + ], + "summary": "Install WebApp.", + "description": "Install the supplied WebApp from the repository.", + "parameters": [ + { + "name": "pkgName", + "in": "path", + "description": "application package name", + "required": true, + "type": "string", + "example": "@signalk/instrumentpanel" + }, + { + "name": "version", + "in": "path", + "description": "application package version", + "required": true, + "type": "string", + "example": "1.3.1" + } + ], + "security": [ + {"cookieAuth": []}, + {"bearerAuth": []} + ], + "responses": { + "200": { + "$ref": "#/components/responses/200Ok" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + }, + "/skServer/appstore/remove/{pkgName}": { + "post": { + "tags": [ + "server" + ], + "summary": "Remove WebApp from server.", + "description": "Un-install the supplied WebApps from the server.", + "parameters": [ + { + "name": "pkgName", + "in": "path", + "description": "application package name", + "required": true, + "type": "string", + "example": "@signalk/instrumentpanel" + } + ], + "security": [ + {"cookieAuth": []}, + {"bearerAuth": []} + ], + "responses": { + "200": { + "$ref": "#/components/responses/200Ok" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + } + } + } diff --git a/src/api/apps/openApi.ts b/src/api/apps/openApi.ts new file mode 100644 index 000000000..603b9e7bf --- /dev/null +++ b/src/api/apps/openApi.ts @@ -0,0 +1,7 @@ +import appsApiDoc from './openApi.json' + +export const appsApiRecord = { + name: 'apps', + path: '/', + apiDoc: appsApiDoc +} diff --git a/src/api/swagger.ts b/src/api/swagger.ts index 525aa50e3..566d6259c 100644 --- a/src/api/swagger.ts +++ b/src/api/swagger.ts @@ -1,6 +1,7 @@ import { NextFunction, Request, Response } from 'express' import swaggerUi from 'swagger-ui-express' import { SERVERROUTESPREFIX } from '../constants' +import { appsApiRecord } from './apps/openApi' import { courseApiRecord } from './course/openApi' import { notificationsApiRecord } from './notifications/openApi' import { resourcesApiRecord } from './resources/openApi' @@ -14,13 +15,16 @@ interface OpenApiRecord { const apiDocs: { [name: string]: OpenApiRecord -} = [courseApiRecord, notificationsApiRecord, resourcesApiRecord, securityApiRecord].reduce( - (acc: any, apiRecord: OpenApiRecord) => { - acc[apiRecord.name] = apiRecord - return acc - }, - {} -) +} = [ + appsApiRecord, + courseApiRecord, + notificationsApiRecord, + resourcesApiRecord, + securityApiRecord +].reduce((acc: any, apiRecord: OpenApiRecord) => { + acc[apiRecord.name] = apiRecord + return acc +}, {}) export function mountSwaggerUi(app: any, path: string) { app.use( From 72d0d0e9c0ff7b2d99ccba4dcc7cd1d270ee55cf Mon Sep 17 00:00:00 2001 From: panaaj <38519157+panaaj@users.noreply.github.com> Date: Fri, 22 Jul 2022 11:09:26 +0930 Subject: [PATCH 59/63] update openapi definitions --- src/api/apps/openApi.json | 117 ++++++++++++++++------------------ src/api/security/openApi.json | 2 +- 2 files changed, 56 insertions(+), 63 deletions(-) diff --git a/src/api/apps/openApi.json b/src/api/apps/openApi.json index 2f1310a6d..7d70a13ea 100644 --- a/src/api/apps/openApi.json +++ b/src/api/apps/openApi.json @@ -15,12 +15,12 @@ }, "tags": [ { - "name": "server", - "description": "Server App / WebApp operations" + "name": "apps", + "description": "Installed applications." }, { - "name": "repository", - "description": "Repository / AppStore operations" + "name": "plugins", + "description": "Installed plugins." } ], "components": { @@ -141,6 +141,41 @@ } } } + }, + "PluginDetailResponse": { + "description": "Plugin detail response.", + "content": { + "application/json": { + "schema": { + "description": "Plugin detail.", + "type": "object", + "required": [ + "enabled", "enabledByDefault", "id", + "name", "version" + ], + "properties": { + "enabled": { + "type": "boolean" + }, + "enabledByDefault": { + "type": "boolean" + }, + "id": { + "type": "string", + "example": "sksim" + }, + "name": { + "type": "string", + "example": "Data stream generator" + }, + "version": { + "type": "string", + "example": "1.5.4" + } + } + } + } + } } }, "securitySchemes": { @@ -161,7 +196,7 @@ "/signalk/v1/apps/list": { "get": { "tags": [ - "server" + "apps" ], "summary": "WebApp list.", "description": "Returns a list of all installed WebApps.", @@ -175,69 +210,27 @@ } } }, - "/skServer/appstore/install/{pkgName}/{version}": { - "post": { - "tags": [ - "repository" - ], - "summary": "Install WebApp.", - "description": "Install the supplied WebApp from the repository.", - "parameters": [ - { - "name": "pkgName", - "in": "path", - "description": "application package name", - "required": true, - "type": "string", - "example": "@signalk/instrumentpanel" - }, - { - "name": "version", - "in": "path", - "description": "application package version", - "required": true, - "type": "string", - "example": "1.3.1" - } - ], - "security": [ - {"cookieAuth": []}, - {"bearerAuth": []} - ], - "responses": { - "200": { - "$ref": "#/components/responses/200Ok" - }, - "default": { - "$ref": "#/components/responses/ErrorResponse" + "/plugins/{id}": { + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Plugin identifier", + "required": true, + "schema": { + "type": "string" } } - } - }, - "/skServer/appstore/remove/{pkgName}": { - "post": { + ], + "get": { "tags": [ - "server" - ], - "summary": "Remove WebApp from server.", - "description": "Un-install the supplied WebApps from the server.", - "parameters": [ - { - "name": "pkgName", - "in": "path", - "description": "application package name", - "required": true, - "type": "string", - "example": "@signalk/instrumentpanel" - } - ], - "security": [ - {"cookieAuth": []}, - {"bearerAuth": []} + "plugins" ], + "summary": "Plugin details.", + "description": "Returns summary details for supplied plugin id.", "responses": { "200": { - "$ref": "#/components/responses/200Ok" + "$ref": "#/components/responses/PluginDetailResponse" }, "default": { "$ref": "#/components/responses/ErrorResponse" diff --git a/src/api/security/openApi.json b/src/api/security/openApi.json index 60a6a4316..e14b56ca1 100644 --- a/src/api/security/openApi.json +++ b/src/api/security/openApi.json @@ -326,7 +326,7 @@ } }, "/auth/logout": { - "post": { + "put": { "tags": [ "authentication" ], From a0751e309cfdcb6ab90fe4eeb282d9004e4a7f7c Mon Sep 17 00:00:00 2001 From: panaaj <38519157+panaaj@users.noreply.github.com> Date: Wed, 3 Aug 2022 11:48:44 +0930 Subject: [PATCH 60/63] openapi: add servers url to fix validation issue --- src/api/resources/openApi.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/api/resources/openApi.json b/src/api/resources/openApi.json index 6352e9a21..98645f57e 100644 --- a/src/api/resources/openApi.json +++ b/src/api/resources/openApi.json @@ -13,6 +13,11 @@ "url": "http://signalk.org/specification/", "description": "Signal K specification." }, + "servers":[ + { + "url": "/signalk/v2/api" + } + ], "tags": [ { "name": "resources", From 051d8ef4624c54ab5caa325b23db3bb0c78e848e Mon Sep 17 00:00:00 2001 From: panaaj <38519157+panaaj@users.noreply.github.com> Date: Fri, 30 Sep 2022 15:19:12 +0930 Subject: [PATCH 61/63] fix course api openApi definition --- src/api/course/openApi.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/api/course/openApi.json b/src/api/course/openApi.json index 01b44f898..8125c660b 100644 --- a/src/api/course/openApi.json +++ b/src/api/course/openApi.json @@ -13,6 +13,11 @@ "url": "http://signalk.org/specification/", "description": "Signal K specification." }, + "servers":[ + { + "url": "/signalk/v2/api/vessels/self/navigation" + } + ], "tags": [ { "name": "course", From 0b23b8d4e4f69a8eea6b98eb9b0f4079787f9240 Mon Sep 17 00:00:00 2001 From: panaaj <38519157+panaaj@users.noreply.github.com> Date: Fri, 30 Sep 2022 15:29:49 +0930 Subject: [PATCH 62/63] fix clearing of activeRoute when dest is set --- src/api/course/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/api/course/index.ts b/src/api/course/index.ts index 3d29625ec..1e5461675 100644 --- a/src/api/course/index.ts +++ b/src/api/course/index.ts @@ -568,6 +568,7 @@ export class CourseApi { newCourse.activeRoute.pointIndex = null newCourse.activeRoute.pointTotal = null newCourse.activeRoute.reverse = null + newCourse.activeRoute.waypoints = null // set previousPoint try { From 171dd95fb59471cfa777c09062b0cc74e1f503e3 Mon Sep 17 00:00:00 2001 From: panaaj <38519157+panaaj@users.noreply.github.com> Date: Mon, 3 Oct 2022 13:08:57 +1030 Subject: [PATCH 63/63] fix openApi defnitions --- src/api/apps/openApi.json | 5 +++++ src/api/notifications/openApi.json | 5 +++++ src/api/security/openApi.json | 5 +++++ 3 files changed, 15 insertions(+) diff --git a/src/api/apps/openApi.json b/src/api/apps/openApi.json index 7d70a13ea..ce938d75c 100644 --- a/src/api/apps/openApi.json +++ b/src/api/apps/openApi.json @@ -13,6 +13,11 @@ "url": "http://signalk.org/specification/", "description": "Signal K specification." }, + "servers":[ + { + "url": "/" + } + ], "tags": [ { "name": "apps", diff --git a/src/api/notifications/openApi.json b/src/api/notifications/openApi.json index 9799a0dbd..1f9662835 100644 --- a/src/api/notifications/openApi.json +++ b/src/api/notifications/openApi.json @@ -13,6 +13,11 @@ "url": "http://signalk.org/specification/", "description": "Signal K specification." }, + "servers":[ + { + "url": "/signalk/v1/api/vessels/self/notifications" + } + ], "tags": [ { "name": "special", diff --git a/src/api/security/openApi.json b/src/api/security/openApi.json index e14b56ca1..96041e52b 100644 --- a/src/api/security/openApi.json +++ b/src/api/security/openApi.json @@ -13,6 +13,11 @@ "url": "http://signalk.org/specification/", "description": "Signal K specification." }, + "servers":[ + { + "url": "/signalk/v1" + } + ], "tags": [ { "name": "authentication",