From 3fd2534a66df8a3073db91aed670f5ce940d8893 Mon Sep 17 00:00:00 2001 From: Teppo Kurki Date: Fri, 17 Feb 2023 19:24:43 +0200 Subject: [PATCH] feature: v2 api with Course and Resources Add initial v2 api with Course and Resources apis, including their OpenAPI descriptions. Add support for handling v1 and v2 deltas separately within the server. Add support for Resource Provider plugins. --- .github/workflows/build-docker.yml | 6 +- .gitignore | 2 + RESOURCE_PROVIDER_PLUGINS.md | 429 ++ SERVERPLUGINS.md | 32 +- WORKING_WITH_COURSE_API.md | 285 ++ WORKING_WITH_RESOURCES_API.md | 199 + docker/v2_demo/Dockerfile | 18 + docker/v2_demo/course-data.json | 10 + docker/v2_demo/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 + .../v2_demo/serverstate/course/settings.json | 1 + docker/v2_demo/startup_heroku_demo.sh | 5 + package.json | 14 +- 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 | 46 + .../src/@types/geojson-validation.d.ts | 1 + .../src/@types/geolib.d.ts | 1 + .../resources-provider-plugin/src/index.ts | 265 ++ .../src/lib/filestorage.ts | 228 + .../src/lib/utils.ts | 182 + .../src/types/index.ts | 1 + .../src/types/store.ts | 18 + .../resources-provider-plugin/tsconfig.json | 32 + packages/server-api/package.json | 2 +- .../src/{types => @types}/baconjs.d.ts | 0 packages/server-api/src/autopilotapi.ts | 27 + packages/server-api/src/deltas.ts | 60 + packages/server-api/src/index.ts | 31 +- packages/server-api/src/resourcesapi.ts | 58 + packages/server-api/src/resourcetypes.ts | 87 + packages/server-api/tsconfig.json | 4 +- public/rapidoc-min.js | 3895 +++++++++++++++++ public/rapidoc.html | 34 + public/redoc-try.html | 11 + settings/n2k-from-file-settings.json | 5 +- src/@types/api-schema-builder.d.ts | 1 + src/api/course/index.ts | 885 ++++ src/api/course/openApi.json | 713 +++ src/api/course/openApi.ts | 7 + src/api/index.ts | 47 + src/api/notifications/openApi.json | 331 ++ src/api/notifications/openApi.ts | 7 + src/api/resources/index.ts | 733 ++++ src/api/resources/openApi.json | 1535 +++++++ src/api/resources/openApi.ts | 7 + src/api/resources/validate.ts | 78 + src/api/swagger.ts | 9 +- src/app.ts | 3 +- src/config/config.ts | 26 +- src/index.ts | 51 +- src/interfaces/plugins.ts | 17 +- src/modules.test.js | 18 +- src/security.ts | 7 + src/serverstate/store.ts | 47 + src/subscriptionmanager.ts | 3 +- src/types.ts | 7 +- src/types/freeport-promise/index.d.ts | 1 + test/course.ts | 441 ++ test/deltacache.js | 12 +- test/multiple-values.js | 3 + .../node_modules/testplugin/index.js | 32 + test/resources.ts | 79 + test/servertestutilities.js | 20 +- test/subscriptions.js | 44 +- test/ts-servertestutilities.ts | 97 + tsconfig.json | 15 +- 74 files changed, 11552 insertions(+), 92 deletions(-) create mode 100644 RESOURCE_PROVIDER_PLUGINS.md create mode 100644 WORKING_WITH_COURSE_API.md create mode 100644 WORKING_WITH_RESOURCES_API.md create mode 100644 docker/v2_demo/Dockerfile create mode 100644 docker/v2_demo/course-data.json create mode 100644 docker/v2_demo/resources-provider.json create mode 100644 docker/v2_demo/resources/routes/ad825f6c-1ae9-4f76-abc4-df2866b14b78 create mode 100644 docker/v2_demo/resources/routes/da825f6c-1ae9-4f76-abc4-df2866b14b78 create mode 100644 docker/v2_demo/resources/waypoints/ac3a3b2d-07e8-4f25-92bc-98e7c92f7f1a create mode 100644 docker/v2_demo/resources/waypoints/afe46290-aa98-4d2f-9c04-d199ca64942e create mode 100644 docker/v2_demo/serverstate/course/settings.json create mode 100644 docker/v2_demo/startup_heroku_demo.sh 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%) create mode 100644 packages/server-api/src/autopilotapi.ts create mode 100644 packages/server-api/src/deltas.ts create mode 100644 packages/server-api/src/resourcesapi.ts create mode 100644 packages/server-api/src/resourcetypes.ts create mode 100644 public/rapidoc-min.js create mode 100644 public/rapidoc.html create mode 100644 public/redoc-try.html 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/course/openApi.ts create mode 100644 src/api/index.ts create mode 100644 src/api/notifications/openApi.json create mode 100644 src/api/notifications/openApi.ts create mode 100644 src/api/resources/index.ts create mode 100644 src/api/resources/openApi.json create mode 100644 src/api/resources/openApi.ts create mode 100644 src/api/resources/validate.ts 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/.github/workflows/build-docker.yml b/.github/workflows/build-docker.yml index 8ba6e9af7..1db58f179 100644 --- a/.github/workflows/build-docker.yml +++ b/.github/workflows/build-docker.yml @@ -3,8 +3,8 @@ name: Build Docker development container on: push: branches: - - master - - 'build-docker' + - master + - "build-docker" tags: - '*' - '!v*' @@ -35,6 +35,8 @@ jobs: npm pack && mv *.tgz ../../ cd ../streams npm pack && mv *.tgz ../../ + cd ../resources-provider-plugin + npm pack && mv *.tgz ../../ cd ../.. jq '.workspaces=[]' package.json > _package.json && mv _package.json package.json npm i --save *.tgz 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/RESOURCE_PROVIDER_PLUGINS.md b/RESOURCE_PROVIDER_PLUGINS.md new file mode 100644 index 000000000..43a31a104 --- /dev/null +++ b/RESOURCE_PROVIDER_PLUGINS.md @@ -0,0 +1,429 @@ +# 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/signalk-server/tree/master/packages/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 functions 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 { + listResources: (query: { [key: string]: any }) => Promise<{[id: string]: any}> + getResource: (id: string, property?: string) => Promise + setResource: ( + id: string, + value: { [key: string]: any } + ) => Promise + deleteResource: (id: string) => Promise +} +``` + + +### Methods and Resource Provider Implementation: + +--- +**The Resource Provider is responsible for implementing the methods and returning data in the required format!** + +--- + +__`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: Return waypoints within the bounded area with lower left corner at E5.4 N25.7 & upper right corner E6.9 & N31.2:_ +``` +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' + } +); +``` + +_Returns:_ +```JSON +{ + "07894aba-f151-4099-aa4f-5e5773734b69": { + "name":"my Point", + "description":"A Signal K waypoint", + "distance":124226.65183615577, + "feature":{ + "type":"Feature", + "geometry":{ + "type":"Point", + "coordinates":[5.7,26.4] + }, + "properties":{} + }, + "timestamp":"2023-01-01T05:02:54.561Z", + "$source":"resources-provider" + }, + "0c894aba-d151-4099-aa4f-be5773734e99": { + "name":"another point", + "description":"Another Signal K waypoint", + "distance":107226.84, + "feature":{ + "type":"Feature", + "geometry":{ + "type":"Point", + "coordinates":[6.1,29.43] + }, + "properties":{} + }, + "timestamp":"2023-01-01T05:02:54.561Z", + "$source":"resources-provider" + } +} +``` + +--- +__`getResource(id, property?)`__: This method is called when a request is made for a specific resource entry with the supplied `id`. If `property` is supplied then the value of the resource property is returned. 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. '07894aba-f151-4099-aa4f-5e5773734b99')_ +- `property` (optional): Name of resource property for which to return the value (in dot notation). _e.g. feature.geometry.coordinates_ + +returns: `Promise` + +_Example resource request:_ +``` +GET /signalk/v2/api/resources/routes/07894aba-f151-4099-aa4f-5e5773734b99 +``` +_ResourceProvider method invocation:_ +```javascript +getResource( + '07894aba-f151-4099-aa4f-5e5773734b99' +); +``` + +_Returns:_ +```JSON +{ + "name":"myRoute", + "description":"A Signal K route", + "distance":124226.65183615577, + "feature":{ + "type":"Feature", + "geometry":{ + "type":"LineString", + "coordinates":[[-8,-8],[-8.5,-8],[-8.5,-8.4],[-8.7,-8.3]] + }, + "properties":{} + }, + "timestamp":"2023-01-01T05:02:54.561Z", + "$source":"resources-provider" +} +``` + +_Example resource property value request:_ +``` +GET /signalk/v2/api/resources/routes/07894aba-f151-4099-aa4f-5e5773734b99/feature/geometry/type +``` +_ResourceProvider method invocation:_ +```javascript +getResource( + '07894aba-f151-4099-aa4f-5e5773734b99', + 'feature.geometry.type' +); +``` + +_Returns:_ +```JSON +{ + "value": "LineString", + "timestamp":"2023-01-01T05:02:54.561Z", + "$source":"resources-provider" +} +``` + +--- +__`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. '07894aba-f151-4099-aa4f-5e5773734b99'_ + +- `value:` Resource data to be stored. + +returns: `Promise` + +_Example PUT resource request:_ +``` +PUT /signalk/v2/api/resources/routes/07894aba-f151-4099-aa4f-5e5773734b99 {resource_data} +``` +_ResourceProvider method invocation:_ + +```javascript +setResource( + '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. '07894aba-f151-4099-aa4f-5e5773734b99'_ + +returns: `Promise` + +_Example resource request:_ +``` +DELETE /signalk/v2/api/resources/routes/07894aba-f151-4099-aa4f-5e5773734b99 +``` +_ResourceProvider method invocation:_ + +```javascript +deleteResource( + '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: More than one plugin can be registered as a provider for a resource type._ + +_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, property?) => { + getRoute(id, property) + ... + }, + setResource: (id, value )=> { + saveRoute(id, value) + ... + }, + deleteResource: (id) => { + deleteRoute(id, value) + ... + } + } + } + + const waypointsProvider: ResourceProvider = { + type: 'waypoints', + methods: { + listResources: (params) => { + fetchWaypoints(params) + ... + }, + getResource: (id, property?) => { + getWaypoint(id, property) + ... + }, + 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, property?) => { + 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 d84ebb55d..c4b7c5abb 100644 --- a/SERVERPLUGINS.md +++ b/SERVERPLUGINS.md @@ -408,7 +408,7 @@ Some easier to understand examples of SignalK plugins are: Internally, SignalK server builds a full data model. Plugins can access the server's delta stream (updates) and full model and provide additional data as deltas using the following functions. -### `app.handleMessage(pluginId, delta)` +### `app.handleMessage(pluginId, delta, skVersion = 'v1')` Allows the plugin to publish deltas to the server. These deltas are handled as any incoming deltas. @@ -427,6 +427,8 @@ app.handleMessage('my-signalk-plugin', { }) ``` +Deltas that use Signal K V2 paths (like the [Course API](http://localhost:3000/admin/openapi/?urls.primaryName=course) paths) should call `handleMessage` with the optional 3rd parameter set to `v2`. This prevents V2 API data getting mixed in V1 paths' data in Full model & the v1 http API. If you don't know that your data is V2 API data you can omit the third parameter, as the default is V1. + ### `app.getSelfPath(path)` Get a Signal K path for the `vessels.self`'s full data model. @@ -709,7 +711,7 @@ app.registerDeltaInputHandler((delta, next) => { See [`RESOURCE_PROVIDER_PLUGINS`](./RESOURCE_PROVIDER_PLUGINS.md) for details. --- -### `app.resourcesApi.getResource(resource_type, resource_id)` +### `app.resourcesApi.getResource(resource_type, resource_id, provider_id?)` Retrieve data for the supplied SignalK resource_type and resource_id. @@ -718,7 +720,9 @@ _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_id`: The id of the resource to retrieve _(e.g. `ac3a3b2d-07e8-4f25-92bc-98e7c92f7f1a`)_ + + - `provider_id` (optional): The id of the Resource Provider plugin to specifically use. Can be specified when more than one provider has been registered for a reource type._(e.g. `resources-provider`)_ - returns: `Promise<{[key: string]: any}>` @@ -726,7 +730,7 @@ _Example:_ ```javascript app.resourcesApi.getResource( 'routes', - 'urn:mrn:signalk:uuid:ac3a3b2d-07e8-4f25-92bc-98e7c92f7f1a' + 'ac3a3b2d-07e8-4f25-92bc-98e7c92f7f1a' ).then (data => { // route data console.log(data); @@ -738,7 +742,7 @@ app.resourcesApi.getResource( } ``` -### `app.resourcesApi.setResource(resource_type, resource_id, resource_data)` +### `app.resourcesApi.setResource(resource_type, resource_id, resource_data, provider_id?)` Create / update value of the resource with the supplied SignalK resource_type and resource_id. @@ -747,17 +751,19 @@ _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_id`: The id of the resource to retrieve _(e.g. `ac3a3b2d-07e8-4f25-92bc-98e7c92f7f1a`)_ - `resource_data`: A complete and valid resource record. + - `provider_id` (optional): The id of the Resource Provider plugin to specifically use. Can be specified when more than one provider has been registered for a reource type._(e.g. `resources-provider`)_ + - returns: `Promise` _Example:_ ```javascript app.resourcesApi.setResource( 'waypoints', - 'urn:mrn:signalk:uuid:ac3a3b2d-07e8-4f25-92bc-98e7c92f7f1a', + 'ac3a3b2d-07e8-4f25-92bc-98e7c92f7f1a', { "position": {"longitude": 138.5, "latitude": -38.6}, "feature": { @@ -779,7 +785,7 @@ app.resourcesApi.setResource( } ``` -### `app.resourcesApi.deleteResource(resource_type, resource_id)` +### `app.resourcesApi.deleteResource(resource_type, resource_id, provider_id?)` Delete the resource with the supplied SignalK resource_type and resource_id. @@ -788,7 +794,9 @@ _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_id`: The id of the resource to retrieve _(e.g. `ac3a3b2d-07e8-4f25-92bc-98e7c92f7f1a`)_ + +- `provider_id` (optional): The id of the Resource Provider plugin to specifically use. Can be specified when more than one provider has been registered for a reource type._(e.g. `resources-provider`)_ - returns: `Promise` @@ -796,7 +804,7 @@ _Example:_ ```javascript app.resourcesApi.deleteResource( 'notes', - 'urn:mrn:signalk:uuid:ac3a3b2d-07e8-4f25-92bc-98e7c92f7f1a' + 'ac3a3b2d-07e8-4f25-92bc-98e7c92f7f1a' ).then ( () => { // success ... @@ -807,7 +815,7 @@ app.resourcesApi.deleteResource( } ``` -### `app.resourcesApi.listResources(resource_type, params)` +### `app.resourcesApi.listResources(resource_type, params, provider_id?)` Retrieve data for the supplied SignalK resource_type and resource_id. @@ -817,6 +825,8 @@ _Note: Requires a registered Resource Provider for the supplied `resource_type`. or user defined resource types. - `params`: Object contining `key | value` pairs repesenting the parameters by which to filter the returned entries. + + - `provider_id` (optional): The id of the Resource Provider plugin to specifically use. Can be specified when more than one provider has been registered for a reource type._(e.g. `resources-provider`)_ __Note: The registered Resource Provider must support the supplied parameters for results to be filtered.__ diff --git a/WORKING_WITH_COURSE_API.md b/WORKING_WITH_COURSE_API.md new file mode 100644 index 000000000..c0e195981 --- /dev/null +++ b/WORKING_WITH_COURSE_API.md @@ -0,0 +1,285 @@ +# Working with the Course API + + +## Overview + +The SignalK Course API provides a consistent means to perform common operations and to ensure that all related Signal K paths set with the relevant values. +It integrates with the [Resources API](WORKING_WITH_RESOURCES_PROVIDER_API.md) to retrieve information about an active route or destination. + +Providing an API to manage the paths under `/signalk/v2/api/vessels/self/navigation/course` ensures the data underpinning course calculations and autopilot operation is consistent and valid. + +Client applications use `HTTP` requests to set (`PUT`), retrieve (`GET`) and clear (`DELETE`) course data. + +_Note: the Course API persists course information on the server to ensure data is not lost in the event of a server restart._ + +**See the [OpenAPI documentation](https://demo.signalk.io/admin/openapi/) in the Admin UI (Server => OpenApi) for more Course API details.** + + +## Retrieving Course Information +--- + +Course information is retrieved by submitting a HTTP `GET` request to `/signalk/v2/api/vessels/self/navigation/course`. + +```typescript +HTTP GET 'http://hostname:3000/signalk/v2/api/vessels/self/navigation/course' +``` +The response will contain values pertaining to the current course. See also [Delta Messages](#delta-messages). + +_Example: Navigate to Location._ +```JSON +{ + "startTime": "2023-01-27T01:47:39.785Z", + "targetArrivalTime": "2022-06-10T01:29:27.592Z", + "arrivalCircle": 4000, + "activeRoute": null, + "nextPoint": { + "type": "Location", + "position": { + "latitude": -34.92084502261776, + "longitude": 131.54823303222656 + } + }, + "previousPoint": { + "type":"VesselPosition", + "position": { + "latitude": -34.82084502261776, + "longitude": 131.04823303222656 + } + } +} +``` + +_Example: Following a route._ +```JSON +{ + "startTime": "2023-01-27T01:47:39.785Z", + "targetArrivalTime": "2022-06-10T01:29:27.592Z", + "arrivalCircle": 1000, + "activeRoute": { + "href": "/resources/routes/e24d72e4-e04b-47b1-920f-66b78e7b0331", + "pointIndex": 0, + "pointTotal": 5, + "reverse": false, + "name": "my route", + "waypoints": [ + { + "latitude": -34.92084502261776, + "longitude": 131.54823303222656 + }, + { + "latitude": -34.86621482446046, + "longitude": 132.10166931152344, + }, + { + "latitude": -34.6309479733581, + "longitude": 132.23350524902344 + }, + { + "latitude": -34.63546778783319, + "longitude": 131.8867492675781 + }, + { + "latitude": -34.71000915922492, + "longitude": 131.82289123535156 + } + ] + }, + "nextPoint": { + "type": "RoutePoint", + "position": { + "latitude": -34.92084502261776, + "longitude": 131.54823303222656 + } + }, + "previousPoint": { + "type":"VesselPosition", + "position": { + "latitude": -34.82084502261776, + "longitude": 131.04823303222656 + } + } +} +``` + +_Example: Navigate to Waypoint._ +```JSON +{ + "startTime": "2023-01-27T01:47:39.785Z", + "targetArrivalTime": "2022-06-10T01:29:27.592Z", + "arrivalCircle": 4000, + "activeRoute": null, + "nextPoint": { + "href": "/resources/waypoints/f24d72e4-e04b-47b1-920f-66b78e7b033e", + "type": "Waypoint", + "position": { + "latitude": -34.92084502261776, + "longitude": 131.54823303222656 + } + }, + "previousPoint": { + "type":"VesselPosition", + "position": { + "latitude": -34.82084502261776, + "longitude": 131.04823303222656 + } + } +} +``` + + +## Setting a Course +--- + +The Course API provides endpoints for: +1. Navigating to a point. +1. Following a Route _(reference to a route entry under `/resources/routes`)_ + + +### 1. Navigating to a Point + +To navigate to a point submit a HTTP `PUT` request to `/signalk/v2/api/vessels/self/navigation/course/destination` and supply either: +- The latitude & longitude of the destination point +- A reference to a waypoint entry under `/resources/waypoints` + +_Example: Setting destination using lat / lon:_ +```typescript +HTTP PUT 'http://hostname:3000/signalk/v2/api/vessels/self/navigation/course/destination' {"position": {"latitude": -60.5, "longitude": -166.7}} +``` + +_Example: Setting waypoint as destination:_ +```typescript +HTTP PUT 'http://hostname:3000/signalk/v2/api/vessels/self/navigation/course/destination' {"href": "/resources/waypoints/5242d307-fbe8-4c65-9059-1f9df1ee126f"} +``` + +### 2. Following a Route + +To follow a route submit a HTTP `PUT` request to `/signalk/v2/api/vessels/self/navigation/course/activeRoute` and supply a reference to a route entry under `/resources/routes`. + +_Example: Following a route:_ +```typescript +HTTP PUT 'http://hostname:3000/signalk/v2/api/vessels/self/navigation/course/activeRoute' {"href": "/resources/routes/5242d307-fbe8-4c65-9059-1f9df1ee126f"} +``` + +Additional parameters can be set when following a route including: +- Defining the point along the route to start at +- The direction to follow the route (forward / reverse) + +_Example: Following a route in reverse direction:_ +```typescript +HTTP PUT 'http://hostname:3000/signalk/v2/api/vessels/self/navigation/course/activeRoute' +{ + "href": "/resources/routes/5242d307-fbe8-4c65-9059-1f9df1ee126f", + "reverse": true +} +``` + +### Advancing along a Route + +As progress along a route is made, you can use the following endpoints to update the destination. + +To set the destination to the next point along the route: +```typescript +HTTP PUT 'http://hostname:3000/signalk/v2/api/vessels/self/navigation/course/activeRoute/nextPoint' +``` + +To advance the destination to a point `n` places beyond the current destination point, supply a positive number representing the number of points to advance: + +```typescript +HTTP PUT 'http://hostname:3000/signalk/v2/api/vessels/self/navigation/course/activeRoute/nextPoint' {"value": 2} +``` +_Sets destination to the point after the next in sequence._ + +To set the destination to the previous point along the route: +```typescript +HTTP PUT 'http://hostname:3000/signalk/v2/api/vessels/self/navigation/course/activeRoute/nextPoint' {"value": -1} +``` + +To set the destination to a point `n` places prior the current destination point, supply a negative number representing the number of points prior: + +```typescript +HTTP PUT 'http://hostname:3000/signalk/v2/api/vessels/self/navigation/course/activeRoute/nextPoint' {"value": -2} +``` +_Sets destination to the point two prior to the current destination._ + +To set the destination to a specific point along the route, supply the zero-based index of the point: + +_Example: 4th point along the route._ +```typescript +HTTP PUT 'http://hostname:3000/signalk/v2/api/vessels/self/navigation/course/activeRoute/pointIndex' {"value": 3} +``` +_Value contains the 'zero-based' index of the point along the route (i.e. 0 = 1st point, 1 = 2nd point, etc.)_ + +To reverse the direction along the route: +```typescript +HTTP PUT 'http://hostname:3000/signalk/v2/api/vessels/self/navigation/course/activeRoute/reverse' +``` + +## Cancelling navigation +--- + +To cancel the current course navigation and clear the course data + +```typescript +HTTP DELETE 'http://hostname:3000/signalk/v2/api/vessels/self/navigation/course/' +``` + + +## Delta Messages +--- + +The Course API emits the delta messages with the following paths when course information has been changed. + +_Note: Delta values reflect the relevant property of the Course Information data object as detailed [above](#retrieving-course-information)._ + +- `navigation.course.startTime` +- `navigation.course.targetArrivalTime` +- `navigation.course.arrivalCircle` +- `navigation.course.activeRoute` +- `navigation.course.nextPoint` +- `navigation.course.previousPoint` + + +## Course Calculations +--- + +The Course API defines the path `/vessels/self/navigation/course/calcValues` to accommodate the calculated values related to course navigation. + +_**Note: The Course API implementation on the server does not perform the calculations to populate these values!**_ + +The following values are defined to be populated by a course computer / plugin based on the Course Information populated by the Course API. + +_Path: `navigation/course/calcValues`_ +``` +calcMethod: "Rhumbline" or "GreatCircle" +crossTrackError +bearingTrackTrue +bearingTrackMagnetic +estimatedTimeOfArrival e.g. "2022-04-22T05:02:56.484Z" +distance +bearingTrue +bearingMagnetic +velocityMadeGood +timeToGo +targetSpeed +previousPoint: { distance } +``` + +_Example:_ +``` +{ + "calcMethod": "Rhumbline", + "crossTrackError": 458.784, + "bearingTrackTrue": 4.58491, + "bearingTrackMagnetic": 4.51234, + "estimatedTimeOfArrival": "2022-04-22T05:02:56.484Z", + "distance": 10157, + "bearingTrue": 4.58491, + "bearingMagnetic": 4.51234, + "velocityMadeGood": 7.2653, + "timeToGo": 8491, + "targetSpeed": 2.2653, + "previousPoint": { + "distance": 10157 + } +} +``` diff --git a/WORKING_WITH_RESOURCES_API.md b/WORKING_WITH_RESOURCES_API.md new file mode 100644 index 000000000..832903d71 --- /dev/null +++ b/WORKING_WITH_RESOURCES_API.md @@ -0,0 +1,199 @@ +# 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/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 `94052456-65fa-48ce-a85d-41b78a9d2111`._ + +```typescript +HTTP DELETE 'http://hostname:3000/signalk/v2/api/resources/routes/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": "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/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._ + + +--- +## Multiple Providers for a Resource Type + +The ResourcesAPI will allow for multiple plugins to register as a provider fo a resource type. + +When this scenario occurs the server services request in the following ways: + +__Listing entries:__ + +When a list of resources is requested + +_for example:_ +```typescript +HTTP GET 'http://hostname:3000/signalk/v2/api/resources/waypoints' +``` + +each registered provider will be asked to return matching entries and the server aggregates the results and returns them to the client. + +--- + +__Requests for specific resources:__ + +When a request is received for a specific resource + +_for example:_ +```typescript +HTTP GET 'http://hostname:3000/signalk/v2/api/resources/waypoints/94052456-65fa-48ce-a85d-41b78a9d2111' + +HTTP PUT 'http://hostname:3000/signalk/v2/api/resources/waypoints/94052456-65fa-48ce-a85d-41b78a9d2111' + +HTTP DELETE 'http://hostname:3000/signalk/v2/api/resources/waypoints/94052456-65fa-48ce-a85d-41b78a9d2111' +``` + +each registered provider will polled to determine which one owns the entry with the supplied id. The provider with the resource entry is then the target of the requested operation (`getResource()`, `setResource()`, `deleteResource()`). + +--- + +__Creating new resource entries:__ + +When a request is received to create a new resource + +_for example:_ +```typescript +HTTP POST 'http://hostname:3000/signalk/v2/api/resources/waypoints' +``` + +the first provider that was registered for that resource type will be the target of the requested operation (`setResource()`). + +--- + +__Specifying the resource provider to be the tartet of the request:__ + +When multiple providers are registered for a resource type the client can specify which provider should be the target of the request by using the query parameter `provider`. + +_Example:_ +```typescript +HTTP GET 'http://hostname:3000/signalk/v2/api/resources/waypoints?provider=provider-plugin-id' + +HTTP GET 'http://hostname:3000/signalk/v2/api/resources/waypoints/94052456-65fa-48ce-a85d-41b78a9d2111?provider=provider-plugin-id' + +HTTP PUT 'http://hostname:3000/signalk/v2/api/resources/waypoints/94052456-65fa-48ce-a85d-41b78a9d2111?provider=provider-plugin-id' + +HTTP DELETE 'http://hostname:3000/signalk/v2/api/resources/waypoints/94052456-65fa-48ce-a85d-41b78a9d2111?provider=provider-plugin-id' + +HTTP POST 'http://hostname:3000/signalk/v2/api/resources/waypoints?provider=provider-plugin-id' +``` + +the value assigned to `provider` is the `plugin id` of the resource provider plugin. + +The plugin id can be obtained from the Signal K server url `http://hostname:3000/plugins`. + +_Example:_ + +```typescript +HTTP GET 'http://hostname:3000/plugins' +``` + +```JSON +[ + { + "id": "sk-resources-fs", // <-- plugin id + "name": "Resources Provider", + "packageName": "sk-resources-fs", + "version": "1.3.0", + ... + }, + ... +] +``` + diff --git a/docker/v2_demo/Dockerfile b/docker/v2_demo/Dockerfile new file mode 100644 index 000000000..bc5f9d2eb --- /dev/null +++ b/docker/v2_demo/Dockerfile @@ -0,0 +1,18 @@ +# 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 + +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 +RUN chown -R node /home/node/.signalk + +USER node diff --git a/docker/v2_demo/course-data.json b/docker/v2_demo/course-data.json new file mode 100644 index 000000000..6910965c9 --- /dev/null +++ b/docker/v2_demo/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/v2_demo/resources-provider.json b/docker/v2_demo/resources-provider.json new file mode 100644 index 000000000..b562c5a87 --- /dev/null +++ b/docker/v2_demo/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/v2_demo/resources/routes/ad825f6c-1ae9-4f76-abc4-df2866b14b78 b/docker/v2_demo/resources/routes/ad825f6c-1ae9-4f76-abc4-df2866b14b78 new file mode 100644 index 000000000..12475d6cb --- /dev/null +++ b/docker/v2_demo/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/v2_demo/resources/routes/da825f6c-1ae9-4f76-abc4-df2866b14b78 b/docker/v2_demo/resources/routes/da825f6c-1ae9-4f76-abc4-df2866b14b78 new file mode 100644 index 000000000..12475d6cb --- /dev/null +++ b/docker/v2_demo/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/v2_demo/resources/waypoints/ac3a3b2d-07e8-4f25-92bc-98e7c92f7f1a b/docker/v2_demo/resources/waypoints/ac3a3b2d-07e8-4f25-92bc-98e7c92f7f1a new file mode 100644 index 000000000..da4aa98d3 --- /dev/null +++ b/docker/v2_demo/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/v2_demo/resources/waypoints/afe46290-aa98-4d2f-9c04-d199ca64942e b/docker/v2_demo/resources/waypoints/afe46290-aa98-4d2f-9c04-d199ca64942e new file mode 100644 index 000000000..a1587b015 --- /dev/null +++ b/docker/v2_demo/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/v2_demo/serverstate/course/settings.json b/docker/v2_demo/serverstate/course/settings.json new file mode 100644 index 000000000..3a8b81186 --- /dev/null +++ b/docker/v2_demo/serverstate/course/settings.json @@ -0,0 +1 @@ +{"activeRoute":{"href":"/resources/routes/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 diff --git a/docker/v2_demo/startup_heroku_demo.sh b/docker/v2_demo/startup_heroku_demo.sh new file mode 100644 index 000000000..2b108f9d0 --- /dev/null +++ b/docker/v2_demo/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 diff --git a/package.json b/package.json index 9634a3949..c09f84fb9 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,10 @@ { "name": "signalk-server", - "version": "1.46.3", + "version": "2.00.0-rc0", "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 && npm run build-declaration", "build:workspaces": "npm run build --workspaces --if-present", "build-declaration": "tsc --declaration", @@ -74,16 +74,20 @@ "packages/server-admin-ui-dependencies", "packages/server-admin-ui", "packages/streams", - "packages/server-api" + "packages/server-api", + "packages/resources-provider-plugin" ], "dependencies": { + "@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", "@signalk/server-admin-ui": "1.46.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", @@ -132,7 +136,7 @@ "ws": "^7.0.0" }, "optionalDependencies": { - "@signalk/freeboard-sk": "^1.0.0", + "@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", @@ -151,6 +155,7 @@ "@types/mocha": "^10.0.1", "@types/ncp": "^2.0.5", "@types/node-fetch": "^2.5.3", + "@types/rmfr": "^2.0.1", "@types/semver": "^7.1.0", "@types/serialport": "^8.0.1", "@types/split": "^1.0.0", @@ -172,6 +177,7 @@ "mocha": "^10.2.0", "prettier": "^2.7.1", "rimraf": "^3.0.2", + "rmfr": "^2.0.0", "ts-node": "^10.9.1", "typedoc": "^0.23.23", "typescript": "^4.8.3" 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..224b9b374 --- /dev/null +++ b/packages/resources-provider-plugin/package.json @@ -0,0 +1,46 @@ +{ + "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", + "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..39aa38019 --- /dev/null +++ b/packages/resources-provider-plugin/src/index.ts @@ -0,0 +1,265 @@ +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: { + path: { + type: 'string', + title: 'Path to store resources:', + description: 'File system path relative to home//.signalk', + default: './resources' + }, + standard: { + type: 'object', + title: 'Resources (standard)', + description: + 'ENABLE / DISABLE 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: 'Add provider for custom resource types.', + items: { + type: 'object', + required: ['name'], + properties: { + name: { + type: 'string', + title: 'Resource Type', + description: '/signalk/v2/api/resources/' + } + } + } + } + } +} + +const CONFIG_UISCHEMA = { + path: { + 'ui:emptyValue': './resources', + 'ui:help': ' ' + }, + standard: { + routes: { + 'ui:widget': 'checkbox', + 'ui:title': ' ', + 'ui:help': '/signalk/v2/api/resources/routes' + }, + waypoints: { + 'ui:widget': 'checkbox', + 'ui:title': ' ', + 'ui:help': '/signalk/v2/api/resources/waypoints' + }, + notes: { + 'ui:widget': 'checkbox', + 'ui:title': ' ', + 'ui:help': '/signalk/v2/api/resources/notes' + }, + regions: { + 'ui:widget': 'checkbox', + 'ui:title': ' ', + 'ui:help': '/signalk/v2/api/resources/regions' + } + } +} + +module.exports = (server: ResourceProviderApp): Plugin => { + const plugin: Plugin = { + id: 'resources-provider', + name: 'Resources Provider (built-in)', + schema: () => CONFIG_SCHEMA, + uiSchema: () => CONFIG_UISCHEMA, + start: (options) => { + 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()}` + + server.setPluginStatus(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) **') + const msg = 'Stopped.' + server.setPluginStatus(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, property?: string) => { + return db.getResource(resType, getUuid(id), property) + }, + 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 + } + return await db.setResource(r) + } + + 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..c052aed4e --- /dev/null +++ b/packages/resources-provider-plugin/src/lib/filestorage.ts @@ -0,0 +1,228 @@ +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 } 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 + } + + // return resource or property value of supplied resource id + async getResource( + type: string, + itemUuid: string, + property?: string + ): Promise { + try { + let result = JSON.parse( + await readFile(path.join(this.resources[type].path, itemUuid), 'utf8') + ) + if (property) { + const value = property.split('.').reduce((acc, val) => { + return acc[val] + }, result) + if (value) { + result = { value: value } + } else { + throw new Error(`${type}/${itemUuid}.${property} not found!`) + } + } + 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') { + throw new Error(`No such resource ${type} ${itemUuid}`) + } + console.error(e) + throw new Error(`Error retrieving resource ${type} ${itemUuid}`) + } + } + + // return persisted resources from storage + async getResources( + type: string, + params: any + ): Promise<{ [key: string]: any }> { + const 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 = files[f] + result[uuid] = res + const stats = await 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 retrieving 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..d0b96dd40 --- /dev/null +++ b/packages/resources-provider-plugin/src/lib/utils.ts @@ -0,0 +1,182 @@ +// utility library functions + +import { + computeDestinationPoint, + getCenterOfBounds, + isPointInPolygon +} from 'geolib' +import ngeohash from 'ngeohash' + +// 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 + if (typeof res.href === 'undefined' || !res.href) { + 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 + 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 +} + +const checkForNumberArray = (param: number[]): Array => { + if (!Array.isArray(param)) { + throw new Error(`Supplied value is not valid! (Array) (${param})`) + } else { + param.forEach((i: number) => { + if (typeof i !== 'number') { + throw new Error( + `Supplied value is not a number array! (Array) (${param})` + ) + } + }) + return param + } +} + +const checkForNumber = (param: number): number => { + if (isNaN(param)) { + throw new Error(`Supplied value is not a number! (${param})`) + } else { + return param + } +} + +// process query parameters +export const processParameters = (params: any) => { + if (typeof params.limit !== 'undefined') { + params.limit = checkForNumber(params.limit) + } + + if (typeof params.bbox !== 'undefined') { + params.bbox = checkForNumberArray(params.bbox) + // 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) { + params.distance = checkForNumber(params.distance) + params.position = checkForNumberArray(params.position) + 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..8987f83f6 --- /dev/null +++ b/packages/resources-provider-plugin/tsconfig.json @@ -0,0 +1,32 @@ +{ + "compilerOptions": { + "target": "es6", + "module": "commonjs", + "outDir": "./plugin", + "esModuleInterop": true, + "strict": true, + "allowJs": true, + "allowSyntheticDefaultImports": true, + "resolveJsonModule": true, + "composite": true, + "rootDir": "src" + }, + "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/package.json b/packages/server-api/package.json index 9dfd63158..089f22374 100644 --- a/packages/server-api/package.json +++ b/packages/server-api/package.json @@ -1,6 +1,6 @@ { "name": "@signalk/server-api", - "version": "1.39.0", + "version": "1.39.1", "description": "signalk-server Typescript API for plugins etc with relevant implementation classes", "main": "dist/index.js", "types": "dist/index.d.ts", 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 diff --git a/packages/server-api/src/autopilotapi.ts b/packages/server-api/src/autopilotapi.ts new file mode 100644 index 000000000..9b4d3dd3f --- /dev/null +++ b/packages/server-api/src/autopilotapi.ts @@ -0,0 +1,27 @@ + +export interface AutopilotApi { + register: (pluginId: string, provider: AutopilotProvider) => void; + unRegister: (pluginId: string) => void; +} + +export interface AutopilotProvider { + pilotType: string + methods: AutopilotProviderMethods +} + +export interface AutopilotProviderMethods { + pluginId?: string + engage: (enable: boolean) => Promise + getConfig: () => Promise<{[key: string]: any}> + getState: () => Promise + setState: (state: string) => Promise + getMode: () => Promise + setMode: (mode: string) => Promise + setTarget: (value: number) => Promise + adjustTarget: (value: number) => Promise + tack: (port: boolean) => Promise +} + +export interface AutopilotProviderRegistry { + registerAutopilotProvider: (provider: AutopilotProvider) => void; +} diff --git a/packages/server-api/src/deltas.ts b/packages/server-api/src/deltas.ts new file mode 100644 index 000000000..ceeef6128 --- /dev/null +++ b/packages/server-api/src/deltas.ts @@ -0,0 +1,60 @@ + +// Delta subscription +export interface DeltaSubscription { + context: string + subscribe: Array<{ + path: string + period: number + format: 'delta' | 'full' + policy: 'instant' | 'ideal' | 'fixed' + minPeriod: number + }> +} + +// Delta Message +export interface DeltaMessage { + updates?: Array<{values: Update[]}> + metas?: Array<{values: Meta[]}> +} + +// Update delta +export interface Update { + path: string + value: object | number | string | null | Notification +} + +// Notification payload +export interface Notification { + state: ALARM_STATE + method: ALARM_METHOD[] + message: string +} + +// MetaMessage +export interface Meta { + path: string + value: MetaValue +} + +// Meta payload +export interface MetaValue { + description: string + units?: string + example?: string +} + +// Notification attribute types +export enum ALARM_STATE { + nominal = 'nominal', + normal = 'normal', + alert = 'alert', + warn = 'warn', + alarm = 'alarm', + emergency = 'emergency' +} + +export enum ALARM_METHOD { + visual = 'visual', + sound = 'sound' +} + diff --git a/packages/server-api/src/index.ts b/packages/server-api/src/index.ts index 00ee85cd4..a7262a1bd 100644 --- a/packages/server-api/src/index.ts +++ b/packages/server-api/src/index.ts @@ -1,6 +1,34 @@ import { IRouter } from 'express' import { PropertyValuesCallback } from './propertyvalues' +export interface Position { + latitude: number + longitude: number + altitude?: number +} + +export interface ActionResult { + state: 'COMPLETED' | 'PENDING' | 'FAILED' + statusCode: number + message?: string + resultStatus?: number +} + +export enum SKVersion { + v1 = 'v1', + v2 = 'v2' +} + +export * from './deltas' +import { DeltaMessage, DeltaSubscription } from './deltas' + +export * from './resourcetypes' +export * from './resourcesapi' +import { ResourceProviderRegistry } from './resourcesapi' + +export * from './autopilotapi' +import { AutopilotProviderRegistry } from './autopilotapi' + export { PropertyValue, PropertyValues, PropertyValuesCallback } from './propertyvalues' type Unsubscribe = () => {} @@ -16,7 +44,8 @@ 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/resourcesapi.ts b/packages/server-api/src/resourcesapi.ts new file mode 100644 index 000000000..e730cec58 --- /dev/null +++ b/packages/server-api/src/resourcesapi.ts @@ -0,0 +1,58 @@ + +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 }, + providerId?: string + ) => Promise<{[id: string]: any}> + getResource: ( + resType: SignalKResourceType, + resId: string, + providerId?: string + ) => Promise + setResource: ( + resType: SignalKResourceType, + resId: string, + data: { [key: string]: any }, + providerId?: string + ) => Promise + deleteResource: ( + resType: SignalKResourceType, + resId: string, + providerId?: string + ) => Promise +} + +export interface ResourceProvider { + type: ResourceType + methods: ResourceProviderMethods +} + +export interface ResourceProviderMethods { + listResources: (query: { [key: string]: any }) => Promise<{[id: string]: any}> + getResource: (id: string, property?: string) => Promise + setResource: ( + id: string, + value: { [key: string]: any } + ) => Promise + deleteResource: (id: string) => Promise +} + +export interface ResourceProviderRegistry { + registerResourceProvider: (provider: ResourceProvider) => void; +} diff --git a/packages/server-api/src/resourcetypes.ts b/packages/server-api/src/resourcetypes.ts new file mode 100644 index 000000000..ce318a88e --- /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 +} + +export type GeoJsonPoint = [number, number, number?] +export type GeoJsonLinestring = GeoJsonPoint[] +export type GeoJsonPolygon = GeoJsonLinestring[] +export 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/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/public/rapidoc-min.js b/public/rapidoc-min.js new file mode 100644 index 000000000..146dcbc34 --- /dev/null +++ b/public/rapidoc-min.js @@ -0,0 +1,3895 @@ +/*! RapiDoc 9.3.4.beta | Author - Mrinmoy Majumdar | License information can be found in rapidoc-min.js.LICENSE.txt */ +(()=>{var e,t,r={656:(e,t,r)=>{"use strict";const n=window,o=n.ShadowRoot&&(void 0===n.ShadyCSS||n.ShadyCSS.nativeShadow)&&"adoptedStyleSheets"in Document.prototype&&"replace"in CSSStyleSheet.prototype,a=Symbol(),i=new WeakMap;class s{constructor(e,t,r){if(this._$cssResult$=!0,r!==a)throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead.");this.cssText=e,this.t=t}get styleSheet(){let e=this.o;const t=this.t;if(o&&void 0===e){const r=void 0!==t&&1===t.length;r&&(e=i.get(t)),void 0===e&&((this.o=e=new CSSStyleSheet).replaceSync(this.cssText),r&&i.set(t,e))}return e}toString(){return this.cssText}}const l=e=>new s("string"==typeof e?e:e+"",void 0,a),c=(e,...t)=>{const r=1===e.length?e[0]:t.reduce(((t,r,n)=>t+(e=>{if(!0===e._$cssResult$)return e.cssText;if("number"==typeof e)return e;throw Error("Value passed to 'css' function must be a 'css' function result: "+e+". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.")})(r)+e[n+1]),e[0]);return new s(r,e,a)},p=o?e=>e:e=>e instanceof CSSStyleSheet?(e=>{let t="";for(const r of e.cssRules)t+=r.cssText;return l(t)})(e):e;var d;const u=window,h=u.trustedTypes,f=h?h.emptyScript:"",m=u.reactiveElementPolyfillSupport,y={toAttribute(e,t){switch(t){case Boolean:e=e?f:null;break;case Object:case Array:e=null==e?e:JSON.stringify(e)}return e},fromAttribute(e,t){let r=e;switch(t){case Boolean:r=null!==e;break;case Number:r=null===e?null:Number(e);break;case Object:case Array:try{r=JSON.parse(e)}catch(e){r=null}}return r}},g=(e,t)=>t!==e&&(t==t||e==e),v={attribute:!0,type:String,converter:y,reflect:!1,hasChanged:g};class b extends HTMLElement{constructor(){super(),this._$Ei=new Map,this.isUpdatePending=!1,this.hasUpdated=!1,this._$El=null,this.u()}static addInitializer(e){var t;this.finalize(),(null!==(t=this.h)&&void 0!==t?t:this.h=[]).push(e)}static get observedAttributes(){this.finalize();const e=[];return this.elementProperties.forEach(((t,r)=>{const n=this._$Ep(r,t);void 0!==n&&(this._$Ev.set(n,r),e.push(n))})),e}static createProperty(e,t=v){if(t.state&&(t.attribute=!1),this.finalize(),this.elementProperties.set(e,t),!t.noAccessor&&!this.prototype.hasOwnProperty(e)){const r="symbol"==typeof e?Symbol():"__"+e,n=this.getPropertyDescriptor(e,r,t);void 0!==n&&Object.defineProperty(this.prototype,e,n)}}static getPropertyDescriptor(e,t,r){return{get(){return this[t]},set(n){const o=this[e];this[t]=n,this.requestUpdate(e,o,r)},configurable:!0,enumerable:!0}}static getPropertyOptions(e){return this.elementProperties.get(e)||v}static finalize(){if(this.hasOwnProperty("finalized"))return!1;this.finalized=!0;const e=Object.getPrototypeOf(this);if(e.finalize(),void 0!==e.h&&(this.h=[...e.h]),this.elementProperties=new Map(e.elementProperties),this._$Ev=new Map,this.hasOwnProperty("properties")){const e=this.properties,t=[...Object.getOwnPropertyNames(e),...Object.getOwnPropertySymbols(e)];for(const r of t)this.createProperty(r,e[r])}return this.elementStyles=this.finalizeStyles(this.styles),!0}static finalizeStyles(e){const t=[];if(Array.isArray(e)){const r=new Set(e.flat(1/0).reverse());for(const e of r)t.unshift(p(e))}else void 0!==e&&t.push(p(e));return t}static _$Ep(e,t){const r=t.attribute;return!1===r?void 0:"string"==typeof r?r:"string"==typeof e?e.toLowerCase():void 0}u(){var e;this._$E_=new Promise((e=>this.enableUpdating=e)),this._$AL=new Map,this._$Eg(),this.requestUpdate(),null===(e=this.constructor.h)||void 0===e||e.forEach((e=>e(this)))}addController(e){var t,r;(null!==(t=this._$ES)&&void 0!==t?t:this._$ES=[]).push(e),void 0!==this.renderRoot&&this.isConnected&&(null===(r=e.hostConnected)||void 0===r||r.call(e))}removeController(e){var t;null===(t=this._$ES)||void 0===t||t.splice(this._$ES.indexOf(e)>>>0,1)}_$Eg(){this.constructor.elementProperties.forEach(((e,t)=>{this.hasOwnProperty(t)&&(this._$Ei.set(t,this[t]),delete this[t])}))}createRenderRoot(){var e;const t=null!==(e=this.shadowRoot)&&void 0!==e?e:this.attachShadow(this.constructor.shadowRootOptions);return((e,t)=>{o?e.adoptedStyleSheets=t.map((e=>e instanceof CSSStyleSheet?e:e.styleSheet)):t.forEach((t=>{const r=document.createElement("style"),o=n.litNonce;void 0!==o&&r.setAttribute("nonce",o),r.textContent=t.cssText,e.appendChild(r)}))})(t,this.constructor.elementStyles),t}connectedCallback(){var e;void 0===this.renderRoot&&(this.renderRoot=this.createRenderRoot()),this.enableUpdating(!0),null===(e=this._$ES)||void 0===e||e.forEach((e=>{var t;return null===(t=e.hostConnected)||void 0===t?void 0:t.call(e)}))}enableUpdating(e){}disconnectedCallback(){var e;null===(e=this._$ES)||void 0===e||e.forEach((e=>{var t;return null===(t=e.hostDisconnected)||void 0===t?void 0:t.call(e)}))}attributeChangedCallback(e,t,r){this._$AK(e,r)}_$EO(e,t,r=v){var n;const o=this.constructor._$Ep(e,r);if(void 0!==o&&!0===r.reflect){const a=(void 0!==(null===(n=r.converter)||void 0===n?void 0:n.toAttribute)?r.converter:y).toAttribute(t,r.type);this._$El=e,null==a?this.removeAttribute(o):this.setAttribute(o,a),this._$El=null}}_$AK(e,t){var r;const n=this.constructor,o=n._$Ev.get(e);if(void 0!==o&&this._$El!==o){const e=n.getPropertyOptions(o),a="function"==typeof e.converter?{fromAttribute:e.converter}:void 0!==(null===(r=e.converter)||void 0===r?void 0:r.fromAttribute)?e.converter:y;this._$El=o,this[o]=a.fromAttribute(t,e.type),this._$El=null}}requestUpdate(e,t,r){let n=!0;void 0!==e&&(((r=r||this.constructor.getPropertyOptions(e)).hasChanged||g)(this[e],t)?(this._$AL.has(e)||this._$AL.set(e,t),!0===r.reflect&&this._$El!==e&&(void 0===this._$EC&&(this._$EC=new Map),this._$EC.set(e,r))):n=!1),!this.isUpdatePending&&n&&(this._$E_=this._$Ej())}async _$Ej(){this.isUpdatePending=!0;try{await this._$E_}catch(e){Promise.reject(e)}const e=this.scheduleUpdate();return null!=e&&await e,!this.isUpdatePending}scheduleUpdate(){return this.performUpdate()}performUpdate(){var e;if(!this.isUpdatePending)return;this.hasUpdated,this._$Ei&&(this._$Ei.forEach(((e,t)=>this[t]=e)),this._$Ei=void 0);let t=!1;const r=this._$AL;try{t=this.shouldUpdate(r),t?(this.willUpdate(r),null===(e=this._$ES)||void 0===e||e.forEach((e=>{var t;return null===(t=e.hostUpdate)||void 0===t?void 0:t.call(e)})),this.update(r)):this._$Ek()}catch(e){throw t=!1,this._$Ek(),e}t&&this._$AE(r)}willUpdate(e){}_$AE(e){var t;null===(t=this._$ES)||void 0===t||t.forEach((e=>{var t;return null===(t=e.hostUpdated)||void 0===t?void 0:t.call(e)})),this.hasUpdated||(this.hasUpdated=!0,this.firstUpdated(e)),this.updated(e)}_$Ek(){this._$AL=new Map,this.isUpdatePending=!1}get updateComplete(){return this.getUpdateComplete()}getUpdateComplete(){return this._$E_}shouldUpdate(e){return!0}update(e){void 0!==this._$EC&&(this._$EC.forEach(((e,t)=>this._$EO(t,this[t],e))),this._$EC=void 0),this._$Ek()}updated(e){}firstUpdated(e){}}var x;b.finalized=!0,b.elementProperties=new Map,b.elementStyles=[],b.shadowRootOptions={mode:"open"},null==m||m({ReactiveElement:b}),(null!==(d=u.reactiveElementVersions)&&void 0!==d?d:u.reactiveElementVersions=[]).push("1.5.0");const w=window,$=w.trustedTypes,k=$?$.createPolicy("lit-html",{createHTML:e=>e}):void 0,S=`lit$${(Math.random()+"").slice(9)}$`,A="?"+S,E=`<${A}>`,O=document,T=(e="")=>O.createComment(e),C=e=>null===e||"object"!=typeof e&&"function"!=typeof e,j=Array.isArray,I=e=>j(e)||"function"==typeof(null==e?void 0:e[Symbol.iterator]),_=/<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g,P=/-->/g,R=/>/g,L=RegExp(">|[ \t\n\f\r](?:([^\\s\"'>=/]+)([ \t\n\f\r]*=[ \t\n\f\r]*(?:[^ \t\n\f\r\"'`<>=]|(\"|')|))|$)","g"),F=/'/g,D=/"/g,B=/^(?:script|style|textarea|title)$/i,N=e=>(t,...r)=>({_$litType$:e,strings:t,values:r}),q=N(1),U=(N(2),Symbol.for("lit-noChange")),z=Symbol.for("lit-nothing"),M=new WeakMap,H=O.createTreeWalker(O,129,null,!1),W=(e,t)=>{const r=e.length-1,n=[];let o,a=2===t?"":"",i=_;for(let t=0;t"===l[0]?(i=null!=o?o:_,c=-1):void 0===l[1]?c=-2:(c=i.lastIndex-l[2].length,s=l[1],i=void 0===l[3]?L:'"'===l[3]?D:F):i===D||i===F?i=L:i===P||i===R?i=_:(i=L,o=void 0);const d=i===L&&e[t+1].startsWith("/>")?" ":"";a+=i===_?r+E:c>=0?(n.push(s),r.slice(0,c)+"$lit$"+r.slice(c)+S+d):r+S+(-2===c?(n.push(void 0),t):d)}const s=a+(e[r]||"")+(2===t?"":"");if(!Array.isArray(e)||!e.hasOwnProperty("raw"))throw Error("invalid template strings array");return[void 0!==k?k.createHTML(s):s,n]};class V{constructor({strings:e,_$litType$:t},r){let n;this.parts=[];let o=0,a=0;const i=e.length-1,s=this.parts,[l,c]=W(e,t);if(this.el=V.createElement(l,r),H.currentNode=this.el.content,2===t){const e=this.el.content,t=e.firstChild;t.remove(),e.append(...t.childNodes)}for(;null!==(n=H.nextNode())&&s.length0){n.textContent=$?$.emptyScript:"";for(let r=0;r2||""!==r[0]||""!==r[1]?(this._$AH=Array(r.length-1).fill(new String),this.strings=r):this._$AH=z}get tagName(){return this.element.tagName}get _$AU(){return this._$AM._$AU}_$AI(e,t=this,r,n){const o=this.strings;let a=!1;if(void 0===o)e=G(this,e,t,0),a=!C(e)||e!==this._$AH&&e!==U,a&&(this._$AH=e);else{const n=e;let i,s;for(e=o[0],i=0;i{var n,o;const a=null!==(n=null==r?void 0:r.renderBefore)&&void 0!==n?n:t;let i=a._$litPart$;if(void 0===i){const e=null!==(o=null==r?void 0:r.renderBefore)&&void 0!==o?o:null;a._$litPart$=i=new J(t.insertBefore(T(),e),e,void 0,null!=r?r:{})}return i._$AI(e),i})(t,this.renderRoot,this.renderOptions)}connectedCallback(){var e;super.connectedCallback(),null===(e=this._$Dt)||void 0===e||e.setConnected(!0)}disconnectedCallback(){var e;super.disconnectedCallback(),null===(e=this._$Dt)||void 0===e||e.setConnected(!1)}render(){return U}}ie.finalized=!0,ie._$litElement$=!0,null===(oe=globalThis.litElementHydrateSupport)||void 0===oe||oe.call(globalThis,{LitElement:ie});const se=globalThis.litElementPolyfillSupport;null==se||se({LitElement:ie});function le(){return{async:!1,baseUrl:null,breaks:!1,extensions:null,gfm:!0,headerIds:!0,headerPrefix:"",highlight:null,langPrefix:"language-",mangle:!0,pedantic:!1,renderer:null,sanitize:!1,sanitizer:null,silent:!1,smartypants:!1,tokenizer:null,walkTokens:null,xhtml:!1}}(null!==(ae=globalThis.litElementVersions)&&void 0!==ae?ae:globalThis.litElementVersions=[]).push("3.2.0");let ce={async:!1,baseUrl:null,breaks:!1,extensions:null,gfm:!0,headerIds:!0,headerPrefix:"",highlight:null,langPrefix:"language-",mangle:!0,pedantic:!1,renderer:null,sanitize:!1,sanitizer:null,silent:!1,smartypants:!1,tokenizer:null,walkTokens:null,xhtml:!1};const pe=/[&<>"']/,de=new RegExp(pe.source,"g"),ue=/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/,he=new RegExp(ue.source,"g"),fe={"&":"&","<":"<",">":">",'"':""","'":"'"},me=e=>fe[e];function ye(e,t){if(t){if(pe.test(e))return e.replace(de,me)}else if(ue.test(e))return e.replace(he,me);return e}const ge=/&(#(?:\d+)|(?:#x[0-9A-Fa-f]+)|(?:\w+));?/gi;function ve(e){return e.replace(ge,((e,t)=>"colon"===(t=t.toLowerCase())?":":"#"===t.charAt(0)?"x"===t.charAt(1)?String.fromCharCode(parseInt(t.substring(2),16)):String.fromCharCode(+t.substring(1)):""))}const be=/(^|[^\[])\^/g;function xe(e,t){e="string"==typeof e?e:e.source,t=t||"";const r={replace:(t,n)=>(n=(n=n.source||n).replace(be,"$1"),e=e.replace(t,n),r),getRegex:()=>new RegExp(e,t)};return r}const we=/[^\w:]/g,$e=/^$|^[a-z][a-z0-9+.-]*:|^[?#]/i;function ke(e,t,r){if(e){let e;try{e=decodeURIComponent(ve(r)).replace(we,"").toLowerCase()}catch(e){return null}if(0===e.indexOf("javascript:")||0===e.indexOf("vbscript:")||0===e.indexOf("data:"))return null}t&&!$e.test(r)&&(r=function(e,t){Se[" "+e]||(Ae.test(e)?Se[" "+e]=e+"/":Se[" "+e]=Ie(e,"/",!0));const r=-1===(e=Se[" "+e]).indexOf(":");return"//"===t.substring(0,2)?r?t:e.replace(Ee,"$1")+t:"/"===t.charAt(0)?r?t:e.replace(Oe,"$1")+t:e+t}(t,r));try{r=encodeURI(r).replace(/%25/g,"%")}catch(e){return null}return r}const Se={},Ae=/^[^:]+:\/*[^/]*$/,Ee=/^([^:]+:)[\s\S]*$/,Oe=/^([^:]+:\/*[^/]*)[\s\S]*$/;const Te={exec:function(){}};function Ce(e){let t,r,n=1;for(;n{let n=!1,o=t;for(;--o>=0&&"\\"===r[o];)n=!n;return n?"|":" |"})).split(/ \|/);let n=0;if(r[0].trim()||r.shift(),r.length>0&&!r[r.length-1].trim()&&r.pop(),r.length>t)r.splice(t);else for(;r.length1;)1&t&&(r+=e),t>>=1,e+=e;return r+e}function Re(e,t,r,n){const o=t.href,a=t.title?ye(t.title):null,i=e[1].replace(/\\([\[\]])/g,"$1");if("!"!==e[0].charAt(0)){n.state.inLink=!0;const e={type:"link",raw:r,href:o,title:a,text:i,tokens:n.inlineTokens(i)};return n.state.inLink=!1,e}return{type:"image",raw:r,href:o,title:a,text:ye(i)}}class Le{constructor(e){this.options=e||ce}space(e){const t=this.rules.block.newline.exec(e);if(t&&t[0].length>0)return{type:"space",raw:t[0]}}code(e){const t=this.rules.block.code.exec(e);if(t){const e=t[0].replace(/^ {1,4}/gm,"");return{type:"code",raw:t[0],codeBlockStyle:"indented",text:this.options.pedantic?e:Ie(e,"\n")}}}fences(e){const t=this.rules.block.fences.exec(e);if(t){const e=t[0],r=function(e,t){const r=e.match(/^(\s+)(?:```)/);if(null===r)return t;const n=r[1];return t.split("\n").map((e=>{const t=e.match(/^\s+/);if(null===t)return e;const[r]=t;return r.length>=n.length?e.slice(n.length):e})).join("\n")}(e,t[3]||"");return{type:"code",raw:e,lang:t[2]?t[2].trim().replace(this.rules.inline._escapes,"$1"):t[2],text:r}}}heading(e){const t=this.rules.block.heading.exec(e);if(t){let e=t[2].trim();if(/#$/.test(e)){const t=Ie(e,"#");this.options.pedantic?e=t.trim():t&&!/ $/.test(t)||(e=t.trim())}return{type:"heading",raw:t[0],depth:t[1].length,text:e,tokens:this.lexer.inline(e)}}}hr(e){const t=this.rules.block.hr.exec(e);if(t)return{type:"hr",raw:t[0]}}blockquote(e){const t=this.rules.block.blockquote.exec(e);if(t){const e=t[0].replace(/^ *>[ \t]?/gm,""),r=this.lexer.state.top;this.lexer.state.top=!0;const n=this.lexer.blockTokens(e);return this.lexer.state.top=r,{type:"blockquote",raw:t[0],tokens:n,text:e}}}list(e){let t=this.rules.block.list.exec(e);if(t){let r,n,o,a,i,s,l,c,p,d,u,h,f=t[1].trim();const m=f.length>1,y={type:"list",raw:"",ordered:m,start:m?+f.slice(0,-1):"",loose:!1,items:[]};f=m?`\\d{1,9}\\${f.slice(-1)}`:`\\${f}`,this.options.pedantic&&(f=m?f:"[*+-]");const g=new RegExp(`^( {0,3}${f})((?:[\t ][^\\n]*)?(?:\\n|$))`);for(;e&&(h=!1,t=g.exec(e))&&!this.rules.block.hr.test(e);){if(r=t[0],e=e.substring(r.length),c=t[2].split("\n",1)[0],p=e.split("\n",1)[0],this.options.pedantic?(a=2,u=c.trimLeft()):(a=t[2].search(/[^ ]/),a=a>4?1:a,u=c.slice(a),a+=t[1].length),s=!1,!c&&/^ *$/.test(p)&&(r+=p+"\n",e=e.substring(p.length+1),h=!0),!h){const t=new RegExp(`^ {0,${Math.min(3,a-1)}}(?:[*+-]|\\d{1,9}[.)])((?: [^\\n]*)?(?:\\n|$))`),n=new RegExp(`^ {0,${Math.min(3,a-1)}}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$)`),o=new RegExp(`^ {0,${Math.min(3,a-1)}}(?:\`\`\`|~~~)`),i=new RegExp(`^ {0,${Math.min(3,a-1)}}#`);for(;e&&(d=e.split("\n",1)[0],c=d,this.options.pedantic&&(c=c.replace(/^ {1,4}(?=( {4})*[^ ])/g," ")),!o.test(c))&&!i.test(c)&&!t.test(c)&&!n.test(e);){if(c.search(/[^ ]/)>=a||!c.trim())u+="\n"+c.slice(a);else{if(s)break;u+="\n"+c}s||c.trim()||(s=!0),r+=d+"\n",e=e.substring(d.length+1)}}y.loose||(l?y.loose=!0:/\n *\n *$/.test(r)&&(l=!0)),this.options.gfm&&(n=/^\[[ xX]\] /.exec(u),n&&(o="[ ] "!==n[0],u=u.replace(/^\[[ xX]\] +/,""))),y.items.push({type:"list_item",raw:r,task:!!n,checked:o,loose:!1,text:u}),y.raw+=r}y.items[y.items.length-1].raw=r.trimRight(),y.items[y.items.length-1].text=u.trimRight(),y.raw=y.raw.trimRight();const v=y.items.length;for(i=0;i"space"===e.type)),t=e.length>0&&e.some((e=>/\n.*\n/.test(e.raw)));y.loose=t}if(y.loose)for(i=0;i$/,"$1").replace(this.rules.inline._escapes,"$1"):"",n=t[3]?t[3].substring(1,t[3].length-1).replace(this.rules.inline._escapes,"$1"):t[3];return{type:"def",tag:e,raw:t[0],href:r,title:n}}}table(e){const t=this.rules.block.table.exec(e);if(t){const e={type:"table",header:je(t[1]).map((e=>({text:e}))),align:t[2].replace(/^ *|\| *$/g,"").split(/ *\| */),rows:t[3]&&t[3].trim()?t[3].replace(/\n[ \t]*$/,"").split("\n"):[]};if(e.header.length===e.align.length){e.raw=t[0];let r,n,o,a,i=e.align.length;for(r=0;r({text:e})));for(i=e.header.length,n=0;n/i.test(t[0])&&(this.lexer.state.inLink=!1),!this.lexer.state.inRawBlock&&/^<(pre|code|kbd|script)(\s|>)/i.test(t[0])?this.lexer.state.inRawBlock=!0:this.lexer.state.inRawBlock&&/^<\/(pre|code|kbd|script)(\s|>)/i.test(t[0])&&(this.lexer.state.inRawBlock=!1),{type:this.options.sanitize?"text":"html",raw:t[0],inLink:this.lexer.state.inLink,inRawBlock:this.lexer.state.inRawBlock,text:this.options.sanitize?this.options.sanitizer?this.options.sanitizer(t[0]):ye(t[0]):t[0]}}link(e){const t=this.rules.inline.link.exec(e);if(t){const e=t[2].trim();if(!this.options.pedantic&&/^$/.test(e))return;const t=Ie(e.slice(0,-1),"\\");if((e.length-t.length)%2==0)return}else{const e=function(e,t){if(-1===e.indexOf(t[1]))return-1;const r=e.length;let n=0,o=0;for(;o-1){const r=(0===t[0].indexOf("!")?5:4)+t[1].length+e;t[2]=t[2].substring(0,e),t[0]=t[0].substring(0,r).trim(),t[3]=""}}let r=t[2],n="";if(this.options.pedantic){const e=/^([^'"]*[^\s])\s+(['"])(.*)\2/.exec(r);e&&(r=e[1],n=e[3])}else n=t[3]?t[3].slice(1,-1):"";return r=r.trim(),/^$/.test(e)?r.slice(1):r.slice(1,-1)),Re(t,{href:r?r.replace(this.rules.inline._escapes,"$1"):r,title:n?n.replace(this.rules.inline._escapes,"$1"):n},t[0],this.lexer)}}reflink(e,t){let r;if((r=this.rules.inline.reflink.exec(e))||(r=this.rules.inline.nolink.exec(e))){let e=(r[2]||r[1]).replace(/\s+/g," ");if(e=t[e.toLowerCase()],!e){const e=r[0].charAt(0);return{type:"text",raw:e,text:e}}return Re(r,e,r[0],this.lexer)}}emStrong(e,t,r=""){let n=this.rules.inline.emStrong.lDelim.exec(e);if(!n)return;if(n[3]&&r.match(/[\p{L}\p{N}]/u))return;const o=n[1]||n[2]||"";if(!o||o&&(""===r||this.rules.inline.punctuation.exec(r))){const r=n[0].length-1;let o,a,i=r,s=0;const l="*"===n[0][0]?this.rules.inline.emStrong.rDelimAst:this.rules.inline.emStrong.rDelimUnd;for(l.lastIndex=0,t=t.slice(-1*e.length+r);null!=(n=l.exec(t));){if(o=n[1]||n[2]||n[3]||n[4]||n[5]||n[6],!o)continue;if(a=o.length,n[3]||n[4]){i+=a;continue}if((n[5]||n[6])&&r%3&&!((r+a)%3)){s+=a;continue}if(i-=a,i>0)continue;a=Math.min(a,a+i+s);const t=e.slice(0,r+n.index+(n[0].length-o.length)+a);if(Math.min(r,a)%2){const e=t.slice(1,-1);return{type:"em",raw:t,text:e,tokens:this.lexer.inlineTokens(e)}}const l=t.slice(2,-2);return{type:"strong",raw:t,text:l,tokens:this.lexer.inlineTokens(l)}}}}codespan(e){const t=this.rules.inline.code.exec(e);if(t){let e=t[2].replace(/\n/g," ");const r=/[^ ]/.test(e),n=/^ /.test(e)&&/ $/.test(e);return r&&n&&(e=e.substring(1,e.length-1)),e=ye(e,!0),{type:"codespan",raw:t[0],text:e}}}br(e){const t=this.rules.inline.br.exec(e);if(t)return{type:"br",raw:t[0]}}del(e){const t=this.rules.inline.del.exec(e);if(t)return{type:"del",raw:t[0],text:t[2],tokens:this.lexer.inlineTokens(t[2])}}autolink(e,t){const r=this.rules.inline.autolink.exec(e);if(r){let e,n;return"@"===r[2]?(e=ye(this.options.mangle?t(r[1]):r[1]),n="mailto:"+e):(e=ye(r[1]),n=e),{type:"link",raw:r[0],text:e,href:n,tokens:[{type:"text",raw:e,text:e}]}}}url(e,t){let r;if(r=this.rules.inline.url.exec(e)){let e,n;if("@"===r[2])e=ye(this.options.mangle?t(r[0]):r[0]),n="mailto:"+e;else{let t;do{t=r[0],r[0]=this.rules.inline._backpedal.exec(r[0])[0]}while(t!==r[0]);e=ye(r[0]),n="www."===r[1]?"http://"+r[0]:r[0]}return{type:"link",raw:r[0],text:e,href:n,tokens:[{type:"text",raw:e,text:e}]}}}inlineText(e,t){const r=this.rules.inline.text.exec(e);if(r){let e;return e=this.lexer.state.inRawBlock?this.options.sanitize?this.options.sanitizer?this.options.sanitizer(r[0]):ye(r[0]):r[0]:ye(this.options.smartypants?t(r[0]):r[0]),{type:"text",raw:r[0],text:e}}}}const Fe={newline:/^(?: *(?:\n|$))+/,code:/^( {4}[^\n]+(?:\n(?: *(?:\n|$))*)?)+/,fences:/^ {0,3}(`{3,}(?=[^`\n]*\n)|~{3,})([^\n]*)\n(?:|([\s\S]*?)\n)(?: {0,3}\1[~`]* *(?=\n|$)|$)/,hr:/^ {0,3}((?:-[\t ]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})(?:\n+|$)/,heading:/^ {0,3}(#{1,6})(?=\s|$)(.*)(?:\n+|$)/,blockquote:/^( {0,3}> ?(paragraph|[^\n]*)(?:\n|$))+/,list:/^( {0,3}bull)([ \t][^\n]+?)?(?:\n|$)/,html:"^ {0,3}(?:<(script|pre|style|textarea)[\\s>][\\s\\S]*?(?:[^\\n]*\\n+|$)|comment[^\\n]*(\\n+|$)|<\\?[\\s\\S]*?(?:\\?>\\n*|$)|\\n*|$)|\\n*|$)|)[\\s\\S]*?(?:(?:\\n *)+\\n|$)|<(?!script|pre|style|textarea)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n *)+\\n|$)|(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n *)+\\n|$))",def:/^ {0,3}\[(label)\]: *(?:\n *)?([^<\s][^\s]*|<.*?>)(?:(?: +(?:\n *)?| *\n *)(title))? *(?:\n+|$)/,table:Te,lheading:/^((?:.|\n(?!\n))+?)\n {0,3}(=+|-+) *(?:\n+|$)/,_paragraph:/^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\n)[^\n]+)*)/,text:/^[^\n]+/,_label:/(?!\s*\])(?:\\.|[^\[\]\\])+/,_title:/(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/};Fe.def=xe(Fe.def).replace("label",Fe._label).replace("title",Fe._title).getRegex(),Fe.bullet=/(?:[*+-]|\d{1,9}[.)])/,Fe.listItemStart=xe(/^( *)(bull) */).replace("bull",Fe.bullet).getRegex(),Fe.list=xe(Fe.list).replace(/bull/g,Fe.bullet).replace("hr","\\n+(?=\\1?(?:(?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$))").replace("def","\\n+(?="+Fe.def.source+")").getRegex(),Fe._tag="address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option|p|param|section|source|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul",Fe._comment=/|$)/,Fe.html=xe(Fe.html,"i").replace("comment",Fe._comment).replace("tag",Fe._tag).replace("attribute",/ +[a-zA-Z:_][\w.:-]*(?: *= *"[^"\n]*"| *= *'[^'\n]*'| *= *[^\s"'=<>`]+)?/).getRegex(),Fe.paragraph=xe(Fe._paragraph).replace("hr",Fe.hr).replace("heading"," {0,3}#{1,6} ").replace("|lheading","").replace("|table","").replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",Fe._tag).getRegex(),Fe.blockquote=xe(Fe.blockquote).replace("paragraph",Fe.paragraph).getRegex(),Fe.normal=Ce({},Fe),Fe.gfm=Ce({},Fe.normal,{table:"^ *([^\\n ].*\\|.*)\\n {0,3}(?:\\| *)?(:?-+:? *(?:\\| *:?-+:? *)*)(?:\\| *)?(?:\\n((?:(?! *\\n|hr|heading|blockquote|code|fences|list|html).*(?:\\n|$))*)\\n*|$)"}),Fe.gfm.table=xe(Fe.gfm.table).replace("hr",Fe.hr).replace("heading"," {0,3}#{1,6} ").replace("blockquote"," {0,3}>").replace("code"," {4}[^\\n]").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",Fe._tag).getRegex(),Fe.gfm.paragraph=xe(Fe._paragraph).replace("hr",Fe.hr).replace("heading"," {0,3}#{1,6} ").replace("|lheading","").replace("table",Fe.gfm.table).replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",Fe._tag).getRegex(),Fe.pedantic=Ce({},Fe.normal,{html:xe("^ *(?:comment *(?:\\n|\\s*$)|<(tag)[\\s\\S]+? *(?:\\n{2,}|\\s*$)|\\s]*)*?/?> *(?:\\n{2,}|\\s*$))").replace("comment",Fe._comment).replace(/tag/g,"(?!(?:a|em|strong|small|s|cite|q|dfn|abbr|data|time|code|var|samp|kbd|sub|sup|i|b|u|mark|ruby|rt|rp|bdi|bdo|span|br|wbr|ins|del|img)\\b)\\w+(?!:|[^\\w\\s@]*@)\\b").getRegex(),def:/^ *\[([^\]]+)\]: *]+)>?(?: +(["(][^\n]+[")]))? *(?:\n+|$)/,heading:/^(#{1,6})(.*)(?:\n+|$)/,fences:Te,lheading:/^(.+?)\n {0,3}(=+|-+) *(?:\n+|$)/,paragraph:xe(Fe.normal._paragraph).replace("hr",Fe.hr).replace("heading"," *#{1,6} *[^\n]").replace("lheading",Fe.lheading).replace("blockquote"," {0,3}>").replace("|fences","").replace("|list","").replace("|html","").getRegex()});const De={escape:/^\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/,autolink:/^<(scheme:[^\s\x00-\x1f<>]*|email)>/,url:Te,tag:"^comment|^|^<[a-zA-Z][\\w-]*(?:attribute)*?\\s*/?>|^<\\?[\\s\\S]*?\\?>|^|^",link:/^!?\[(label)\]\(\s*(href)(?:\s+(title))?\s*\)/,reflink:/^!?\[(label)\]\[(ref)\]/,nolink:/^!?\[(ref)\](?:\[\])?/,reflinkSearch:"reflink|nolink(?!\\()",emStrong:{lDelim:/^(?:\*+(?:([punct_])|[^\s*]))|^_+(?:([punct*])|([^\s_]))/,rDelimAst:/^(?:[^_*\\]|\\.)*?\_\_(?:[^_*\\]|\\.)*?\*(?:[^_*\\]|\\.)*?(?=\_\_)|(?:[^*\\]|\\.)+(?=[^*])|[punct_](\*+)(?=[\s]|$)|(?:[^punct*_\s\\]|\\.)(\*+)(?=[punct_\s]|$)|[punct_\s](\*+)(?=[^punct*_\s])|[\s](\*+)(?=[punct_])|[punct_](\*+)(?=[punct_])|(?:[^punct*_\s\\]|\\.)(\*+)(?=[^punct*_\s])/,rDelimUnd:/^(?:[^_*\\]|\\.)*?\*\*(?:[^_*\\]|\\.)*?\_(?:[^_*\\]|\\.)*?(?=\*\*)|(?:[^_\\]|\\.)+(?=[^_])|[punct*](\_+)(?=[\s]|$)|(?:[^punct*_\s\\]|\\.)(\_+)(?=[punct*\s]|$)|[punct*\s](\_+)(?=[^punct*_\s])|[\s](\_+)(?=[punct*])|[punct*](\_+)(?=[punct*])/},code:/^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/,br:/^( {2,}|\\)\n(?!\s*$)/,del:Te,text:/^(`+|[^`])(?:(?= {2,}\n)|[\s\S]*?(?:(?=[\\.5&&(r="x"+r.toString(16)),n+="&#"+r+";";return n}De._punctuation="!\"#$%&'()+\\-.,/:;<=>?@\\[\\]`^{|}~",De.punctuation=xe(De.punctuation).replace(/punctuation/g,De._punctuation).getRegex(),De.blockSkip=/\[[^\]]*?\]\([^\)]*?\)|`[^`]*?`|<[^>]*?>/g,De.escapedEmSt=/(?:^|[^\\])(?:\\\\)*\\[*_]/g,De._comment=xe(Fe._comment).replace("(?:--\x3e|$)","--\x3e").getRegex(),De.emStrong.lDelim=xe(De.emStrong.lDelim).replace(/punct/g,De._punctuation).getRegex(),De.emStrong.rDelimAst=xe(De.emStrong.rDelimAst,"g").replace(/punct/g,De._punctuation).getRegex(),De.emStrong.rDelimUnd=xe(De.emStrong.rDelimUnd,"g").replace(/punct/g,De._punctuation).getRegex(),De._escapes=/\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/g,De._scheme=/[a-zA-Z][a-zA-Z0-9+.-]{1,31}/,De._email=/[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+(@)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?![-_])/,De.autolink=xe(De.autolink).replace("scheme",De._scheme).replace("email",De._email).getRegex(),De._attribute=/\s+[a-zA-Z:_][\w.:-]*(?:\s*=\s*"[^"]*"|\s*=\s*'[^']*'|\s*=\s*[^\s"'=<>`]+)?/,De.tag=xe(De.tag).replace("comment",De._comment).replace("attribute",De._attribute).getRegex(),De._label=/(?:\[(?:\\.|[^\[\]\\])*\]|\\.|`[^`]*`|[^\[\]\\`])*?/,De._href=/<(?:\\.|[^\n<>\\])+>|[^\s\x00-\x1f]*/,De._title=/"(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)/,De.link=xe(De.link).replace("label",De._label).replace("href",De._href).replace("title",De._title).getRegex(),De.reflink=xe(De.reflink).replace("label",De._label).replace("ref",Fe._label).getRegex(),De.nolink=xe(De.nolink).replace("ref",Fe._label).getRegex(),De.reflinkSearch=xe(De.reflinkSearch,"g").replace("reflink",De.reflink).replace("nolink",De.nolink).getRegex(),De.normal=Ce({},De),De.pedantic=Ce({},De.normal,{strong:{start:/^__|\*\*/,middle:/^__(?=\S)([\s\S]*?\S)__(?!_)|^\*\*(?=\S)([\s\S]*?\S)\*\*(?!\*)/,endAst:/\*\*(?!\*)/g,endUnd:/__(?!_)/g},em:{start:/^_|\*/,middle:/^()\*(?=\S)([\s\S]*?\S)\*(?!\*)|^_(?=\S)([\s\S]*?\S)_(?!_)/,endAst:/\*(?!\*)/g,endUnd:/_(?!_)/g},link:xe(/^!?\[(label)\]\((.*?)\)/).replace("label",De._label).getRegex(),reflink:xe(/^!?\[(label)\]\s*\[([^\]]*)\]/).replace("label",De._label).getRegex()}),De.gfm=Ce({},De.normal,{escape:xe(De.escape).replace("])","~|])").getRegex(),_extended_email:/[A-Za-z0-9._+-]+(@)[a-zA-Z0-9-_]+(?:\.[a-zA-Z0-9-_]*[a-zA-Z0-9])+(?![-_])/,url:/^((?:ftp|https?):\/\/|www\.)(?:[a-zA-Z0-9\-]+\.?)+[^\s<]*|^email/,_backpedal:/(?:[^?!.,:;*_'"~()&]+|\([^)]*\)|&(?![a-zA-Z0-9]+;$)|[?!.,:;*_'"~)]+(?!$))+/,del:/^(~~?)(?=[^\s~])([\s\S]*?[^\s~])\1(?=[^~]|$)/,text:/^([`~]+|[^`~])(?:(?= {2,}\n)|(?=[a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-]+@)|[\s\S]*?(?:(?=[\\t+" ".repeat(r.length)));e;)if(!(this.options.extensions&&this.options.extensions.block&&this.options.extensions.block.some((n=>!!(r=n.call({lexer:this},e,t))&&(e=e.substring(r.raw.length),t.push(r),!0)))))if(r=this.tokenizer.space(e))e=e.substring(r.raw.length),1===r.raw.length&&t.length>0?t[t.length-1].raw+="\n":t.push(r);else if(r=this.tokenizer.code(e))e=e.substring(r.raw.length),n=t[t.length-1],!n||"paragraph"!==n.type&&"text"!==n.type?t.push(r):(n.raw+="\n"+r.raw,n.text+="\n"+r.text,this.inlineQueue[this.inlineQueue.length-1].src=n.text);else if(r=this.tokenizer.fences(e))e=e.substring(r.raw.length),t.push(r);else if(r=this.tokenizer.heading(e))e=e.substring(r.raw.length),t.push(r);else if(r=this.tokenizer.hr(e))e=e.substring(r.raw.length),t.push(r);else if(r=this.tokenizer.blockquote(e))e=e.substring(r.raw.length),t.push(r);else if(r=this.tokenizer.list(e))e=e.substring(r.raw.length),t.push(r);else if(r=this.tokenizer.html(e))e=e.substring(r.raw.length),t.push(r);else if(r=this.tokenizer.def(e))e=e.substring(r.raw.length),n=t[t.length-1],!n||"paragraph"!==n.type&&"text"!==n.type?this.tokens.links[r.tag]||(this.tokens.links[r.tag]={href:r.href,title:r.title}):(n.raw+="\n"+r.raw,n.text+="\n"+r.raw,this.inlineQueue[this.inlineQueue.length-1].src=n.text);else if(r=this.tokenizer.table(e))e=e.substring(r.raw.length),t.push(r);else if(r=this.tokenizer.lheading(e))e=e.substring(r.raw.length),t.push(r);else{if(o=e,this.options.extensions&&this.options.extensions.startBlock){let t=1/0;const r=e.slice(1);let n;this.options.extensions.startBlock.forEach((function(e){n=e.call({lexer:this},r),"number"==typeof n&&n>=0&&(t=Math.min(t,n))})),t<1/0&&t>=0&&(o=e.substring(0,t+1))}if(this.state.top&&(r=this.tokenizer.paragraph(o)))n=t[t.length-1],a&&"paragraph"===n.type?(n.raw+="\n"+r.raw,n.text+="\n"+r.text,this.inlineQueue.pop(),this.inlineQueue[this.inlineQueue.length-1].src=n.text):t.push(r),a=o.length!==e.length,e=e.substring(r.raw.length);else if(r=this.tokenizer.text(e))e=e.substring(r.raw.length),n=t[t.length-1],n&&"text"===n.type?(n.raw+="\n"+r.raw,n.text+="\n"+r.text,this.inlineQueue.pop(),this.inlineQueue[this.inlineQueue.length-1].src=n.text):t.push(r);else if(e){const t="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(t);break}throw new Error(t)}}return this.state.top=!0,t}inline(e,t=[]){return this.inlineQueue.push({src:e,tokens:t}),t}inlineTokens(e,t=[]){let r,n,o,a,i,s,l=e;if(this.tokens.links){const e=Object.keys(this.tokens.links);if(e.length>0)for(;null!=(a=this.tokenizer.rules.inline.reflinkSearch.exec(l));)e.includes(a[0].slice(a[0].lastIndexOf("[")+1,-1))&&(l=l.slice(0,a.index)+"["+Pe("a",a[0].length-2)+"]"+l.slice(this.tokenizer.rules.inline.reflinkSearch.lastIndex))}for(;null!=(a=this.tokenizer.rules.inline.blockSkip.exec(l));)l=l.slice(0,a.index)+"["+Pe("a",a[0].length-2)+"]"+l.slice(this.tokenizer.rules.inline.blockSkip.lastIndex);for(;null!=(a=this.tokenizer.rules.inline.escapedEmSt.exec(l));)l=l.slice(0,a.index+a[0].length-2)+"++"+l.slice(this.tokenizer.rules.inline.escapedEmSt.lastIndex),this.tokenizer.rules.inline.escapedEmSt.lastIndex--;for(;e;)if(i||(s=""),i=!1,!(this.options.extensions&&this.options.extensions.inline&&this.options.extensions.inline.some((n=>!!(r=n.call({lexer:this},e,t))&&(e=e.substring(r.raw.length),t.push(r),!0)))))if(r=this.tokenizer.escape(e))e=e.substring(r.raw.length),t.push(r);else if(r=this.tokenizer.tag(e))e=e.substring(r.raw.length),n=t[t.length-1],n&&"text"===r.type&&"text"===n.type?(n.raw+=r.raw,n.text+=r.text):t.push(r);else if(r=this.tokenizer.link(e))e=e.substring(r.raw.length),t.push(r);else if(r=this.tokenizer.reflink(e,this.tokens.links))e=e.substring(r.raw.length),n=t[t.length-1],n&&"text"===r.type&&"text"===n.type?(n.raw+=r.raw,n.text+=r.text):t.push(r);else if(r=this.tokenizer.emStrong(e,l,s))e=e.substring(r.raw.length),t.push(r);else if(r=this.tokenizer.codespan(e))e=e.substring(r.raw.length),t.push(r);else if(r=this.tokenizer.br(e))e=e.substring(r.raw.length),t.push(r);else if(r=this.tokenizer.del(e))e=e.substring(r.raw.length),t.push(r);else if(r=this.tokenizer.autolink(e,Ne))e=e.substring(r.raw.length),t.push(r);else if(this.state.inLink||!(r=this.tokenizer.url(e,Ne))){if(o=e,this.options.extensions&&this.options.extensions.startInline){let t=1/0;const r=e.slice(1);let n;this.options.extensions.startInline.forEach((function(e){n=e.call({lexer:this},r),"number"==typeof n&&n>=0&&(t=Math.min(t,n))})),t<1/0&&t>=0&&(o=e.substring(0,t+1))}if(r=this.tokenizer.inlineText(o,Be))e=e.substring(r.raw.length),"_"!==r.raw.slice(-1)&&(s=r.raw.slice(-1)),i=!0,n=t[t.length-1],n&&"text"===n.type?(n.raw+=r.raw,n.text+=r.text):t.push(r);else if(e){const t="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(t);break}throw new Error(t)}}else e=e.substring(r.raw.length),t.push(r);return t}}class Ue{constructor(e){this.options=e||ce}code(e,t,r){const n=(t||"").match(/\S*/)[0];if(this.options.highlight){const t=this.options.highlight(e,n);null!=t&&t!==e&&(r=!0,e=t)}return e=e.replace(/\n$/,"")+"\n",n?'
'+(r?e:ye(e,!0))+"
\n":"
"+(r?e:ye(e,!0))+"
\n"}blockquote(e){return`
\n${e}
\n`}html(e){return e}heading(e,t,r,n){if(this.options.headerIds){return`${e}\n`}return`${e}\n`}hr(){return this.options.xhtml?"
\n":"
\n"}list(e,t,r){const n=t?"ol":"ul";return"<"+n+(t&&1!==r?' start="'+r+'"':"")+">\n"+e+"\n"}listitem(e){return`
  • ${e}
  • \n`}checkbox(e){return" "}paragraph(e){return`

    ${e}

    \n`}table(e,t){return t&&(t=`${t}`),"\n\n"+e+"\n"+t+"
    \n"}tablerow(e){return`\n${e}\n`}tablecell(e,t){const r=t.header?"th":"td";return(t.align?`<${r} align="${t.align}">`:`<${r}>`)+e+`\n`}strong(e){return`${e}`}em(e){return`${e}`}codespan(e){return`${e}`}br(){return this.options.xhtml?"
    ":"
    "}del(e){return`${e}`}link(e,t,r){if(null===(e=ke(this.options.sanitize,this.options.baseUrl,e)))return r;let n='",n}image(e,t,r){if(null===(e=ke(this.options.sanitize,this.options.baseUrl,e)))return r;let n=`${r}":">",n}text(e){return e}}class ze{strong(e){return e}em(e){return e}codespan(e){return e}del(e){return e}html(e){return e}text(e){return e}link(e,t,r){return""+r}image(e,t,r){return""+r}br(){return""}}class Me{constructor(){this.seen={}}serialize(e){return e.toLowerCase().trim().replace(/<[!\/a-z].*?>/gi,"").replace(/[\u2000-\u206F\u2E00-\u2E7F\\'!"#$%&()*+,./:;<=>?@[\]^`{|}~]/g,"").replace(/\s/g,"-")}getNextSafeSlug(e,t){let r=e,n=0;if(this.seen.hasOwnProperty(r)){n=this.seen[e];do{n++,r=e+"-"+n}while(this.seen.hasOwnProperty(r))}return t||(this.seen[e]=n,this.seen[r]=0),r}slug(e,t={}){const r=this.serialize(e);return this.getNextSafeSlug(r,t.dryrun)}}class He{constructor(e){this.options=e||ce,this.options.renderer=this.options.renderer||new Ue,this.renderer=this.options.renderer,this.renderer.options=this.options,this.textRenderer=new ze,this.slugger=new Me}static parse(e,t){return new He(t).parse(e)}static parseInline(e,t){return new He(t).parseInline(e)}parse(e,t=!0){let r,n,o,a,i,s,l,c,p,d,u,h,f,m,y,g,v,b,x,w="";const $=e.length;for(r=0;r<$;r++)if(d=e[r],this.options.extensions&&this.options.extensions.renderers&&this.options.extensions.renderers[d.type]&&(x=this.options.extensions.renderers[d.type].call({parser:this},d),!1!==x||!["space","hr","heading","code","table","blockquote","list","html","paragraph","text"].includes(d.type)))w+=x||"";else switch(d.type){case"space":continue;case"hr":w+=this.renderer.hr();continue;case"heading":w+=this.renderer.heading(this.parseInline(d.tokens),d.depth,ve(this.parseInline(d.tokens,this.textRenderer)),this.slugger);continue;case"code":w+=this.renderer.code(d.text,d.lang,d.escaped);continue;case"table":for(c="",l="",a=d.header.length,n=0;n0&&"paragraph"===y.tokens[0].type?(y.tokens[0].text=b+" "+y.tokens[0].text,y.tokens[0].tokens&&y.tokens[0].tokens.length>0&&"text"===y.tokens[0].tokens[0].type&&(y.tokens[0].tokens[0].text=b+" "+y.tokens[0].tokens[0].text)):y.tokens.unshift({type:"text",text:b}):m+=b),m+=this.parse(y.tokens,f),p+=this.renderer.listitem(m,v,g);w+=this.renderer.list(p,u,h);continue;case"html":w+=this.renderer.html(d.text);continue;case"paragraph":w+=this.renderer.paragraph(this.parseInline(d.tokens));continue;case"text":for(p=d.tokens?this.parseInline(d.tokens):d.text;r+1<$&&"text"===e[r+1].type;)d=e[++r],p+="\n"+(d.tokens?this.parseInline(d.tokens):d.text);w+=t?this.renderer.paragraph(p):p;continue;default:{const e='Token with "'+d.type+'" type was not found.';if(this.options.silent)return void console.error(e);throw new Error(e)}}return w}parseInline(e,t){t=t||this.renderer;let r,n,o,a="";const i=e.length;for(r=0;r{n(e.text,e.lang,(function(t,r){if(t)return a(t);null!=r&&r!==e.text&&(e.text=r,e.escaped=!0),i--,0===i&&a()}))}),0))})),void(0===i&&a())}function n(e){if(e.message+="\nPlease report this to https://github.com/markedjs/marked.",t.silent)return"

    An error occurred:

    "+ye(e.message+"",!0)+"
    ";throw e}try{const r=qe.lex(e,t);if(t.walkTokens){if(t.async)return Promise.all(We.walkTokens(r,t.walkTokens)).then((()=>He.parse(r,t))).catch(n);We.walkTokens(r,t.walkTokens)}return He.parse(r,t)}catch(e){n(e)}}We.options=We.setOptions=function(e){var t;return Ce(We.defaults,e),t=We.defaults,ce=t,We},We.getDefaults=le,We.defaults=ce,We.use=function(...e){const t=We.defaults.extensions||{renderers:{},childTokens:{}};e.forEach((e=>{const r=Ce({},e);if(r.async=We.defaults.async||r.async,e.extensions&&(e.extensions.forEach((e=>{if(!e.name)throw new Error("extension name required");if(e.renderer){const r=t.renderers[e.name];t.renderers[e.name]=r?function(...t){let n=e.renderer.apply(this,t);return!1===n&&(n=r.apply(this,t)),n}:e.renderer}if(e.tokenizer){if(!e.level||"block"!==e.level&&"inline"!==e.level)throw new Error("extension level must be 'block' or 'inline'");t[e.level]?t[e.level].unshift(e.tokenizer):t[e.level]=[e.tokenizer],e.start&&("block"===e.level?t.startBlock?t.startBlock.push(e.start):t.startBlock=[e.start]:"inline"===e.level&&(t.startInline?t.startInline.push(e.start):t.startInline=[e.start]))}e.childTokens&&(t.childTokens[e.name]=e.childTokens)})),r.extensions=t),e.renderer){const t=We.defaults.renderer||new Ue;for(const r in e.renderer){const n=t[r];t[r]=(...o)=>{let a=e.renderer[r].apply(t,o);return!1===a&&(a=n.apply(t,o)),a}}r.renderer=t}if(e.tokenizer){const t=We.defaults.tokenizer||new Le;for(const r in e.tokenizer){const n=t[r];t[r]=(...o)=>{let a=e.tokenizer[r].apply(t,o);return!1===a&&(a=n.apply(t,o)),a}}r.tokenizer=t}if(e.walkTokens){const t=We.defaults.walkTokens;r.walkTokens=function(r){let n=[];return n.push(e.walkTokens.call(this,r)),t&&(n=n.concat(t.call(this,r))),n}}We.setOptions(r)}))},We.walkTokens=function(e,t){let r=[];for(const n of e)switch(r=r.concat(t.call(We,n)),n.type){case"table":for(const e of n.header)r=r.concat(We.walkTokens(e.tokens,t));for(const e of n.rows)for(const n of e)r=r.concat(We.walkTokens(n.tokens,t));break;case"list":r=r.concat(We.walkTokens(n.items,t));break;default:We.defaults.extensions&&We.defaults.extensions.childTokens&&We.defaults.extensions.childTokens[n.type]?We.defaults.extensions.childTokens[n.type].forEach((function(e){r=r.concat(We.walkTokens(n[e],t))})):n.tokens&&(r=r.concat(We.walkTokens(n.tokens,t)))}return r},We.parseInline=function(e,t){if(null==e)throw new Error("marked.parseInline(): input parameter is undefined or null");if("string"!=typeof e)throw new Error("marked.parseInline(): input parameter is of type "+Object.prototype.toString.call(e)+", string expected");_e(t=Ce({},We.defaults,t||{}));try{const r=qe.lexInline(e,t);return t.walkTokens&&We.walkTokens(r,t.walkTokens),He.parseInline(r,t)}catch(e){if(e.message+="\nPlease report this to https://github.com/markedjs/marked.",t.silent)return"

    An error occurred:

    "+ye(e.message+"",!0)+"
    ";throw e}},We.Parser=He,We.parser=He.parse,We.Renderer=Ue,We.TextRenderer=ze,We.Lexer=qe,We.lexer=qe.lex,We.Tokenizer=Le,We.Slugger=Me,We.parse=We;We.options,We.setOptions,We.use,We.walkTokens,We.parseInline,He.parse,qe.lex;var Ve=r(660),Ge=r.n(Ve);r(251),r(358),r(46),r(503),r(277),r(874),r(366),r(57),r(16);const Ke=c` + .hover-bg:hover{ + background: var(--bg3); + } + ::selection { + background: var(--selection-bg); + color: var(--selection-fg); + } + .regular-font{ + font-family:var(--font-regular); + } + .mono-font { + font-family:var(--font-mono); + } + .title { + font-size: calc(var(--font-size-small) + 18px); + font-weight: normal + } + .sub-title{ font-size: 20px;} + .req-res-title { + font-family: var(--font-regular); + font-size: calc(var(--font-size-small) + 4px); + font-weight:bold; + margin-bottom:8px; + text-align:left; + } + .tiny-title { + font-size:calc(var(--font-size-small) + 1px); + font-weight:bold; + } + .regular-font-size { font-size: var(--font-size-regular); } + .small-font-size { font-size: var(--font-size-small); } + .upper { text-transform: uppercase; } + .primary-text{ color: var(--primary-color); } + .bold-text { font-weight:bold; } + .gray-text { color: var(--light-fg); } + .red-text {color: var(--red)} + .blue-text {color: var(--blue)} + .multiline { + overflow: scroll; + max-height: var(--resp-area-height, 400px); + color: var(--fg3); + } + .method-fg.put { color: var(--orange); } + .method-fg.post { color: var(--green); } + .method-fg.get { color: var(--blue); } + .method-fg.delete { color: var(--red); } + .method-fg.options, + .method-fg.head, + .method-fg.patch { + color: var(--yellow); + } + + h1{ font-family:var(--font-regular); font-size:28px; padding-top: 10px; letter-spacing:normal; font-weight:normal; } + h2{ font-family:var(--font-regular); font-size:24px; padding-top: 10px; letter-spacing:normal; font-weight:normal; } + h3{ font-family:var(--font-regular); font-size:18px; padding-top: 10px; letter-spacing:normal; font-weight:normal; } + h4{ font-family:var(--font-regular); font-size:16px; padding-top: 10px; letter-spacing:normal; font-weight:normal; } + h5{ font-family:var(--font-regular); font-size:14px; padding-top: 10px; letter-spacing:normal; font-weight:normal; } + h6{ font-family:var(--font-regular); font-size:14px; padding-top: 10px; letter-spacing:normal; font-weight:normal; } + + h1,h2,h3,h4,h5,h5{ + margin-block-end: 0.2em; + } + p { margin-block-start: 0.5em; } + a { color: var(--blue); cursor:pointer; } + a.inactive-link { + color:var(--fg); + text-decoration: none; + cursor:text; + } + + code, + pre { + margin: 0px; + font-family: var(--font-mono); + font-size: calc(var(--font-size-mono) - 1px); + } + + .m-markdown, + .m-markdown-small { + display:block; + } + + .m-markdown p, + .m-markdown span { + font-size: var(--font-size-regular); + line-height:calc(var(--font-size-regular) + 8px); + } + .m-markdown li { + font-size: var(--font-size-regular); + line-height:calc(var(--font-size-regular) + 10px); + } + + .m-markdown-small p, + .m-markdown-small span, + .m-markdown-small li { + font-size: var(--font-size-small); + line-height: calc(var(--font-size-small) + 6px); + } + .m-markdown-small li { + line-height: calc(var(--font-size-small) + 8px); + } + + .m-markdown p:not(:first-child) { + margin-block-start: 24px; + } + + .m-markdown-small p:not(:first-child) { + margin-block-start: 12px; + } + .m-markdown-small p:first-child { + margin-block-start: 0; + } + + .m-markdown p, + .m-markdown-small p { + margin-block-end: 0 + } + + .m-markdown code span { + font-size:var(--font-size-mono); + } + + .m-markdown-small code, + .m-markdown code { + padding: 1px 6px; + border-radius: 2px; + color: var(--inline-code-fg); + background-color: var(--bg3); + font-size: calc(var(--font-size-mono)); + line-height: 1.2; + } + + .m-markdown-small code { + font-size: calc(var(--font-size-mono) - 1px); + } + + .m-markdown-small pre, + .m-markdown pre { + white-space: pre-wrap; + overflow-x: auto; + line-height: normal; + border-radius: 2px; + border: 1px solid var(--code-border-color); + } + + .m-markdown pre { + padding: 12px; + background-color: var(--code-bg); + color:var(--code-fg); + } + + .m-markdown-small pre { + margin-top: 4px; + padding: 2px 4px; + background-color: var(--bg3); + color: var(--fg2); + } + + .m-markdown-small pre code, + .m-markdown pre code { + border:none; + padding:0; + } + + .m-markdown pre code { + color: var(--code-fg); + background-color: var(--code-bg); + background-color: transparent; + } + + .m-markdown-small pre code { + color: var(--fg2); + background-color: var(--bg3); + } + + .m-markdown ul, + .m-markdown ol { + padding-inline-start: 30px; + } + + .m-markdown-small ul, + .m-markdown-small ol { + padding-inline-start: 20px; + } + + .m-markdown-small a, + .m-markdown a { + color:var(--blue); + } + + .m-markdown-small img, + .m-markdown img { + max-width: 100%; + } + + /* Markdown table */ + + .m-markdown-small table, + .m-markdown table { + border-spacing: 0; + margin: 10px 0; + border-collapse: separate; + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + font-size: calc(var(--font-size-small) + 1px); + line-height: calc(var(--font-size-small) + 4px); + max-width: 100%; + } + + .m-markdown-small table { + font-size: var(--font-size-small); + line-height: calc(var(--font-size-small) + 2px); + margin: 8px 0; + } + + .m-markdown-small td, + .m-markdown-small th, + .m-markdown td, + .m-markdown th { + vertical-align: top; + border-top: 1px solid var(--border-color); + line-height: calc(var(--font-size-small) + 4px); + } + + .m-markdown-small tr:first-child th, + .m-markdown tr:first-child th { + border-top: 0 none; + } + + .m-markdown th, + .m-markdown td { + padding: 10px 12px; + } + + .m-markdown-small th, + .m-markdown-small td { + padding: 8px 8px; + } + + .m-markdown th, + .m-markdown-small th { + font-weight: 600; + background-color: var(--bg2); + vertical-align: middle; + } + + .m-markdown-small table code { + font-size: calc(var(--font-size-mono) - 2px); + } + + .m-markdown table code { + font-size: calc(var(--font-size-mono) - 1px); + } + + .m-markdown blockquote, + .m-markdown-small blockquote { + margin-inline-start: 0; + margin-inline-end: 0; + border-left: 3px solid var(--border-color); + padding: 6px 0 6px 6px; + } + .m-markdown hr{ + border: 1px solid var(--border-color); + } +`,Je=c` +/* Button */ +.m-btn { + border-radius: var(--border-radius); + font-weight: 600; + display: inline-block; + padding: 6px 16px; + font-size: var(--font-size-small); + outline: 0; + line-height: 1; + text-align: center; + white-space: nowrap; + border: 2px solid var(--primary-color); + background-color:transparent; + transition: background-color 0.2s; + user-select: none; + cursor: pointer; + box-shadow: 0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24); +} +.m-btn.primary { + background-color: var(--primary-color); + color: var(--primary-color-invert); +} +.m-btn.thin-border { border-width: 1px; } +.m-btn.large { padding:8px 14px; } +.m-btn.small { padding:5px 12px; } +.m-btn.tiny { padding:5px 6px; } +.m-btn.circle { border-radius: 50%; } +.m-btn:hover { + background-color: var(--primary-color); + color: var(--primary-color-invert); +} +.m-btn.nav { border: 2px solid var(--nav-accent-color); } +.m-btn.nav:hover { + background-color: var(--nav-accent-color); +} +.m-btn:disabled{ + background-color: var(--bg3); + color: var(--fg3); + border-color: var(--fg3); + cursor: not-allowed; + opacity: 0.4; +} +.toolbar-btn{ + cursor: pointer; + padding: 4px; + margin:0 2px; + font-size: var(--font-size-small); + min-width: 50px; + color: var(--primary-color-invert); + border-radius: 2px; + border: none; + background-color: var(--primary-color); +} + +input, textarea, select, button, pre { + color:var(--fg); + outline: none; + background-color: var(--input-bg); + border: 1px solid var(--border-color); + border-radius: var(--border-radius); +} +button { + font-family: var(--font-regular); +} + +/* Form Inputs */ +pre, +select, +textarea, +input[type="file"], +input[type="text"], +input[type="password"] { + font-family: var(--font-mono); + font-weight: 400; + font-size: var(--font-size-small); + transition: border .2s; + padding: 6px 5px; +} + +select { + font-family: var(--font-regular); + padding: 5px 30px 5px 5px; + background-image: url("data:image/svg+xml;charset=utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2212%22%20height%3D%2212%22%3E%3Cpath%20d%3D%22M10.3%203.3L6%207.6%201.7%203.3A1%201%200%2000.3%204.7l5%205a1%201%200%20001.4%200l5-5a1%201%200%2010-1.4-1.4z%22%20fill%3D%22%23777777%22%2F%3E%3C%2Fsvg%3E"); + background-position: calc(100% - 5px) center; + background-repeat: no-repeat; + background-size: 10px; + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + cursor: pointer; +} + +select:hover { + border-color: var(--primary-color); +} + +textarea::placeholder, +input[type="text"]::placeholder, +input[type="password"]::placeholder { + color: var(--placeholder-color); + opacity:1; +} + + +input[type="file"]{ + font-family: var(--font-regular); + padding:2px; + cursor:pointer; + border: 1px solid var(--primary-color); + min-height: calc(var(--font-size-small) + 18px); +} + +input[type="file"]::-webkit-file-upload-button { + font-family: var(--font-regular); + font-size: var(--font-size-small); + outline: none; + cursor:pointer; + padding: 3px 8px; + border: 1px solid var(--primary-color); + background-color: var(--primary-color); + color: var(--primary-color-invert); + border-radius: var(--border-radius);; + -webkit-appearance: none; +} + +pre, +textarea { + scrollbar-width: thin; + scrollbar-color: var(--border-color) var(--input-bg); +} + +pre::-webkit-scrollbar, +textarea::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +pre::-webkit-scrollbar-track, +textarea::-webkit-scrollbar-track { + background:var(--input-bg); +} + +pre::-webkit-scrollbar-thumb, +textarea::-webkit-scrollbar-thumb { + border-radius: 2px; + background-color: var(--border-color); +} + +.link { + font-size:var(--font-size-small); + text-decoration: underline; + color:var(--blue); + font-family:var(--font-mono); + margin-bottom:2px; +} + +/* Toggle Body */ +input[type="checkbox"] { + appearance: none; + display: inline-block; + background-color: var(--light-bg); + border: 1px solid var(--light-bg); + border-radius: 9px; + cursor: pointer; + height: 18px; + position: relative; + transition: border .25s .15s, box-shadow .25s .3s, padding .25s; + min-width: 36px; + width: 36px; + vertical-align: top; +} +/* Toggle Thumb */ +input[type="checkbox"]:after { + position: absolute; + background-color: var(--bg); + border: 1px solid var(--light-bg); + border-radius: 8px; + content: ''; + top: 0px; + left: 0px; + right: 16px; + display: block; + height: 16px; + transition: border .25s .15s, left .25s .1s, right .15s .175s; +} + +/* Toggle Body - Checked */ +input[type="checkbox"]:checked { + background-color: var(--green); + border-color: var(--green); +} +/* Toggle Thumb - Checked*/ +input[type="checkbox"]:checked:after { + border: 1px solid var(--green); + left: 16px; + right: 1px; + transition: border .25s, left .15s .25s, right .25s .175s; +}`,Ye=c` +.row, .col{ + display:flex; +} +.row { + align-items:center; + flex-direction: row; +} +.col { + align-items:stretch; + flex-direction: column; +} +`,Ze=c` +.m-table { + border-spacing: 0; + border-collapse: separate; + border: 1px solid var(--light-border-color); + border-radius: var(--border-radius); + margin: 0; + max-width: 100%; + direction: ltr; +} +.m-table tr:first-child td, +.m-table tr:first-child th { + border-top: 0 none; +} +.m-table td, +.m-table th { + font-size: var(--font-size-small); + line-height: calc(var(--font-size-small) + 4px); + padding: 4px 5px 4px; + vertical-align: top; +} + +.m-table.padded-12 td, +.m-table.padded-12 th { + padding: 12px; +} + +.m-table td:not([align]), +.m-table th:not([align]) { + text-align: left; +} + +.m-table th { + color: var(--fg2); + font-size: var(--font-size-small); + line-height: calc(var(--font-size-small) + 18px); + font-weight: 600; + letter-spacing: normal; + background-color: var(--bg2); + vertical-align: bottom; + border-bottom: 1px solid var(--light-border-color); +} + +.m-table > tbody > tr > td, +.m-table > tr > td { + border-top: 1px solid var(--light-border-color); + text-overflow: ellipsis; + overflow: hidden; +} +.table-title { + font-size:var(--font-size-small); + font-weight:bold; + vertical-align: middle; + margin: 12px 0 4px 0; +} +`,Qe=c` +.only-large-screen { display:none; } +.endpoint-head .path{ + display: flex; + font-family:var(--font-mono); + font-size: var(--font-size-small); + align-items: center; + overflow-wrap: break-word; + word-break: break-all; +} + +.endpoint-head .descr { + font-size: var(--font-size-small); + color:var(--light-fg); + font-weight:400; + align-items: center; + overflow-wrap: break-word; + word-break: break-all; + display:none; +} + +.m-endpoint.expanded{margin-bottom:16px; } +.m-endpoint > .endpoint-head{ + border-width:1px 1px 1px 5px; + border-style:solid; + border-color:transparent; + border-top-color:var(--light-border-color); + display:flex; + padding:6px 16px; + align-items: center; + cursor: pointer; +} +.m-endpoint > .endpoint-head.put:hover, +.m-endpoint > .endpoint-head.put.expanded{ + border-color:var(--orange); + background-color:var(--light-orange); +} +.m-endpoint > .endpoint-head.post:hover, +.m-endpoint > .endpoint-head.post.expanded { + border-color:var(--green); + background-color:var(--light-green); +} +.m-endpoint > .endpoint-head.get:hover, +.m-endpoint > .endpoint-head.get.expanded { + border-color:var(--blue); + background-color:var(--light-blue); +} +.m-endpoint > .endpoint-head.delete:hover, +.m-endpoint > .endpoint-head.delete.expanded { + border-color:var(--red); + background-color:var(--light-red); +} + +.m-endpoint > .endpoint-head.head:hover, +.m-endpoint > .endpoint-head.head.expanded, +.m-endpoint > .endpoint-head.patch:hover, +.m-endpoint > .endpoint-head.patch.expanded, +.m-endpoint > .endpoint-head.options:hover, +.m-endpoint > .endpoint-head.options.expanded { + border-color:var(--yellow); + background-color:var(--light-yellow); +} + +.m-endpoint > .endpoint-head.deprecated:hover, +.m-endpoint > .endpoint-head.deprecated.expanded { + border-color:var(--border-color); + filter:opacity(0.6); +} + +.m-endpoint .endpoint-body { + flex-wrap:wrap; + padding:16px 0px 0 0px; + border-width:0px 1px 1px 5px; + border-style:solid; + box-shadow: 0px 4px 3px -3px rgba(0, 0, 0, 0.15); +} +.m-endpoint .endpoint-body.delete{ border-color:var(--red); } +.m-endpoint .endpoint-body.put{ border-color:var(--orange); } +.m-endpoint .endpoint-body.post{border-color:var(--green);} +.m-endpoint .endpoint-body.get{ border-color:var(--blue); } +.m-endpoint .endpoint-body.head, +.m-endpoint .endpoint-body.patch, +.m-endpoint .endpoint-body.options { + border-color:var(--yellow); +} + +.m-endpoint .endpoint-body.deprecated{ + border-color:var(--border-color); + filter:opacity(0.6); +} + +.endpoint-head .deprecated{ + color: var(--light-fg); + filter:opacity(0.6); +} + +.summary{ + padding:8px 8px; +} +.summary .title{ + font-size:calc(var(--font-size-regular) + 2px); + margin-bottom: 6px; + word-break: break-all; +} + +.endpoint-head .method{ + padding:2px 5px; + vertical-align: middle; + font-size:var(--font-size-small); + height: calc(var(--font-size-small) + 16px); + line-height: calc(var(--font-size-small) + 8px); + width: 60px; + border-radius: 2px; + display:inline-block; + text-align: center; + font-weight: bold; + text-transform:uppercase; + margin-right:5px; +} +.endpoint-head .method.delete{ border: 2px solid var(--red);} +.endpoint-head .method.put{ border: 2px solid var(--orange); } +.endpoint-head .method.post{ border: 2px solid var(--green); } +.endpoint-head .method.get{ border: 2px solid var(--blue); } +.endpoint-head .method.get.deprecated{ border: 2px solid var(--border-color); } +.endpoint-head .method.head, +.endpoint-head .method.patch, +.endpoint-head .method.options { + border: 2px solid var(--yellow); +} + +.req-resp-container { + display: flex; + margin-top:16px; + align-items: stretch; + flex-wrap: wrap; + flex-direction: column; + border-top:1px solid var(--light-border-color); +} + +.view-mode-request, +api-response.view-mode { + flex:1; + min-height:100px; + padding:16px 8px; + overflow:hidden; +} +.view-mode-request { + border-width:0 0 1px 0; + border-style:dashed; +} + +.head .view-mode-request, +.patch .view-mode-request, +.options .view-mode-request { + border-color:var(--yellow); +} +.put .view-mode-request { + border-color:var(--orange); +} +.post .view-mode-request { + border-color:var(--green); +} +.get .view-mode-request { + border-color:var(--blue); +} +.delete .view-mode-request { + border-color:var(--red); +} + +@media only screen and (min-width: 1024px) { + .only-large-screen { display:block; } + .endpoint-head .path{ + font-size: var(--font-size-regular); + } + .endpoint-head .descr{ + display: flex; + } + .endpoint-head .m-markdown-small, + .descr .m-markdown-small{ + display:block; + } + .req-resp-container{ + flex-direction: var(--layout, row); + flex-wrap: nowrap; + } + api-response.view-mode { + padding:16px; + } + .view-mode-request.row-layout { + border-width:0 1px 0 0; + padding:16px; + } + .summary{ + padding:8px 16px; + } +} +`,Xe=c` +code[class*="language-"], +pre[class*="language-"] { + text-align: left; + white-space: pre; + word-spacing: normal; + word-break: normal; + word-wrap: normal; + line-height: 1.5; + tab-size: 2; + + -webkit-hyphens: none; + -moz-hyphens: none; + -ms-hyphens: none; + hyphens: none; +} + +/* Code blocks */ +pre[class*="language-"] { + padding: 1em; + margin: .5em 0; + overflow: auto; +} + +/* Inline code */ +:not(pre) > code[class*="language-"] { + white-space: normal; +} + +.token.comment, +.token.block-comment, +.token.prolog, +.token.doctype, +.token.cdata { + color: var(--light-fg) +} + +.token.punctuation { + color: var(--fg); +} + +.token.tag, +.token.attr-name, +.token.namespace, +.token.deleted { + color:var(--pink); +} + +.token.function-name { + color: var(--blue); +} + +.token.boolean, +.token.number, +.token.function { + color: var(--red); +} + +.token.property, +.token.class-name, +.token.constant, +.token.symbol { + color: var(--code-property-color); +} + +.token.selector, +.token.important, +.token.atrule, +.token.keyword, +.token.builtin { + color: var(--code-keyword-color); +} + +.token.string, +.token.char, +.token.attr-value, +.token.regex, +.token.variable { + color: var(--green); +} + +.token.operator, +.token.entity, +.token.url { + color: var(--code-operator-color); +} + +.token.important, +.token.bold { + font-weight: bold; +} +.token.italic { + font-style: italic; +} + +.token.entity { + cursor: help; +} + +.token.inserted { + color: green; +} +`,et=c` +.tab-panel { + border: none; +} +.tab-buttons { + height:30px; + padding: 4px 4px 0 4px; + border-bottom: 1px solid var(--light-border-color) ; + align-items: stretch; + overflow-y: hidden; + overflow-x: auto; + scrollbar-width: thin; +} +.tab-buttons::-webkit-scrollbar { + height: 1px; + background-color: var(--border-color); +} +.tab-btn { + border: none; + border-bottom: 3px solid transparent; + color: var(--light-fg); + background-color: transparent; + white-space: nowrap; + cursor:pointer; + outline:none; + font-family:var(--font-regular); + font-size:var(--font-size-small); + margin-right:16px; + padding:1px; +} +.tab-btn.active { + border-bottom: 3px solid var(--primary-color); + font-weight:bold; + color:var(--primary-color); +} + +.tab-btn:hover { + color:var(--primary-color); +} +.tab-content { + margin:-1px 0 0 0; + position:relative; + min-height: 50px; +} +`,tt=c` +.nav-bar-info:focus-visible, +.nav-bar-tag:focus-visible, +.nav-bar-path:focus-visible { + outline: 1px solid; + box-shadow: none; + outline-offset: -4px; +} +.nav-bar-expand-all:focus-visible, +.nav-bar-collapse-all:focus-visible, +.nav-bar-tag-icon:focus-visible { + outline: 1px solid; + box-shadow: none; + outline-offset: 2px; +} +.nav-bar { + width:0; + height:100%; + overflow: hidden; + color:var(--nav-text-color); + background-color: var(--nav-bg-color); + background-blend-mode: multiply; + line-height: calc(var(--font-size-small) + 4px); + display:none; + position:relative; + flex-direction:column; + flex-wrap:nowrap; + word-break:break-word; +} +::slotted([slot=nav-logo]){ + padding:16px 16px 0 16px; +} +.nav-scroll { + overflow-x: hidden; + overflow-y: auto; + overflow-y: overlay; + scrollbar-width: thin; + scrollbar-color: var(--nav-hover-bg-color) transparent; +} + +.nav-bar-tag { + display: flex; + align-items: center; + justify-content: space-between; + flex-direction: row; +} +.nav-bar.read .nav-bar-tag-icon { + display:none; +} +.nav-bar-paths-under-tag { + overflow:hidden; + transition: max-height .2s ease-out, visibility .3s; +} +.collapsed .nav-bar-paths-under-tag { + visibility: hidden; +} + +.nav-bar-expand-all { + transform: rotate(90deg); + cursor:pointer; + margin-right:10px; +} +.nav-bar-collapse-all { + transform: rotate(270deg); + cursor:pointer; +} +.nav-bar-expand-all:hover, .nav-bar-collapse-all:hover { + color: var(--primary-color); +} + +.nav-bar-tag-icon { + color: var(--nav-text-color); + font-size: 20px; +} +.nav-bar-tag-icon:hover { + color:var(--nav-hover-text-color); +} +.nav-bar.focused .nav-bar-tag-and-paths.collapsed .nav-bar-tag-icon::after { + content: '⌵'; + width:16px; + height:16px; + text-align: center; + display: inline-block; + transform: rotate(-90deg); + transition: transform 0.2s ease-out 0s; +} +.nav-bar.focused .nav-bar-tag-and-paths.expanded .nav-bar-tag-icon::after { + content: '⌵'; + width:16px; + height:16px; + text-align: center; + display: inline-block; + transition: transform 0.2s ease-out 0s; +} +.nav-scroll::-webkit-scrollbar { + width: var(--scroll-bar-width, 8px); +} +.nav-scroll::-webkit-scrollbar-track { + background:transparent; +} +.nav-scroll::-webkit-scrollbar-thumb { + background-color: var(--nav-hover-bg-color); +} + +.nav-bar-tag { + font-size: var(--font-size-regular); + color: var(--nav-accent-color); + border-left:4px solid transparent; + font-weight:bold; + padding: 15px 15px 15px 10px; + text-transform: capitalize; +} + +.nav-bar-components, +.nav-bar-h1, +.nav-bar-h2, +.nav-bar-info, +.nav-bar-tag, +.nav-bar-path { + display:flex; + cursor: pointer; + width: 100%; + border: none; + border-radius:4px; + color: var(--nav-text-color); + background: transparent; + border-left:4px solid transparent; +} + +.nav-bar-h1, +.nav-bar-h2, +.nav-bar-path { + font-size: calc(var(--font-size-small) + 1px); + padding: var(--nav-item-padding); +} +.nav-bar-path.small-font { + font-size: var(--font-size-small); +} + +.nav-bar-info { + font-size: var(--font-size-regular); + padding: 16px 10px; + font-weight:bold; +} +.nav-bar-section { + display: flex; + flex-direction: row; + justify-content: space-between; + font-size: var(--font-size-small); + color: var(--nav-text-color); + padding: var(--nav-item-padding); + font-weight:bold; +} +.nav-bar-section.operations { + cursor:pointer; +} +.nav-bar-section.operations:hover { + color:var(--nav-hover-text-color); + background-color:var(--nav-hover-bg-color); +} + +.nav-bar-section:first-child { + display: none; +} +.nav-bar-h2 {margin-left:12px;} + +.nav-bar-h1.left-bar.active, +.nav-bar-h2.left-bar.active, +.nav-bar-info.left-bar.active, +.nav-bar-tag.left-bar.active, +.nav-bar-path.left-bar.active, +.nav-bar-section.left-bar.operations.active { + border-left:4px solid var(--nav-accent-color); + color:var(--nav-hover-text-color); +} + +.nav-bar-h1.colored-block.active, +.nav-bar-h2.colored-block.active, +.nav-bar-info.colored-block.active, +.nav-bar-tag.colored-block.active, +.nav-bar-path.colored-block.active, +.nav-bar-section.colored-block.operations.active { + background-color: var(--nav-accent-color); + color: var(--nav-accent-text-color); + border-radius: 0; +} + +.nav-bar-h1:hover, +.nav-bar-h2:hover, +.nav-bar-info:hover, +.nav-bar-tag:hover, +.nav-bar-path:hover { + color:var(--nav-hover-text-color); + background-color:var(--nav-hover-bg-color); +} +`,rt=c` +#api-info { + font-size: calc(var(--font-size-regular) - 1px); + margin-top: 8px; + margin-left: -15px; +} + +#api-info span:before { + content: "|"; + display: inline-block; + opacity: 0.5; + width: 15px; + text-align: center; +} +#api-info span:first-child:before { + content: ""; + width: 0px; +} +`,nt=c` + +`;const ot=/[\s#:?&={}]/g,at="_rapidoc_api_key";function it(e){return new Promise((t=>setTimeout(t,e)))}function st(e,t){const r=t.target,n=document.createElement("textarea");n.value=e,n.style.position="fixed",document.body.appendChild(n),n.focus(),n.select();try{document.execCommand("copy"),r.innerText="Copied",setTimeout((()=>{r.innerText="Copy"}),5e3)}catch(e){console.error("Unable to copy",e)}document.body.removeChild(n)}function lt(e,t,r="includes"){if("includes"===r){return`${t.method} ${t.path} ${t.summary||t.description||""} ${t.operationId||""}`.toLowerCase().includes(e.toLowerCase())}return new RegExp(e,"i").test(`${t.method} ${t.path}`)}function ct(e,t=new Set){return e?(Object.keys(e).forEach((r=>{var n;if(t.add(r),e[r].properties)ct(e[r].properties,t);else if(null!==(n=e[r].items)&&void 0!==n&&n.properties){var o;ct(null===(o=e[r].items)||void 0===o?void 0:o.properties,t)}})),t):t}function pt(e,t){if(e){const r=document.createElement("a");document.body.appendChild(r),r.style="display: none",r.href=e,r.download=t,r.click(),r.remove()}}function dt(e){if(e){const t=document.createElement("a");document.body.appendChild(t),t.style="display: none",t.href=e,t.target="_blank",t.click(),t.remove()}}function ut(e){return e&&e.t&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}var ht=function(e){return e&&e.Math==Math&&e},ft=ht("object"==typeof globalThis&&globalThis)||ht("object"==typeof window&&window)||ht("object"==typeof self&&self)||ht("object"==typeof ft&&ft)||function(){return this}()||Function("return this")(),mt=function(e){try{return!!e()}catch(e){return!0}},yt=!mt((function(){var e=function(){}.bind();return"function"!=typeof e||e.hasOwnProperty("prototype")})),gt=yt,vt=Function.prototype,bt=vt.apply,xt=vt.call,wt="object"==typeof Reflect&&Reflect.apply||(gt?xt.bind(bt):function(){return xt.apply(bt,arguments)}),$t=yt,kt=Function.prototype,St=kt.bind,At=kt.call,Et=$t&&St.bind(At,At),Ot=$t?function(e){return e&&Et(e)}:function(e){return e&&function(){return At.apply(e,arguments)}},Tt=function(e){return"function"==typeof e},Ct={},jt=!mt((function(){return 7!=Object.defineProperty({},1,{get:function(){return 7}})[1]})),It=yt,_t=Function.prototype.call,Pt=It?_t.bind(_t):function(){return _t.apply(_t,arguments)},Rt={},Lt={}.propertyIsEnumerable,Ft=Object.getOwnPropertyDescriptor,Dt=Ft&&!Lt.call({1:2},1);Rt.f=Dt?function(e){var t=Ft(this,e);return!!t&&t.enumerable}:Lt;var Bt,Nt,qt=function(e,t){return{enumerable:!(1&e),configurable:!(2&e),writable:!(4&e),value:t}},Ut=Ot,zt=Ut({}.toString),Mt=Ut("".slice),Ht=function(e){return Mt(zt(e),8,-1)},Wt=Ot,Vt=mt,Gt=Ht,Kt=ft.Object,Jt=Wt("".split),Yt=Vt((function(){return!Kt("z").propertyIsEnumerable(0)}))?function(e){return"String"==Gt(e)?Jt(e,""):Kt(e)}:Kt,Zt=ft.TypeError,Qt=function(e){if(null==e)throw Zt("Can't call method on "+e);return e},Xt=Yt,er=Qt,tr=function(e){return Xt(er(e))},rr=Tt,nr=function(e){return"object"==typeof e?null!==e:rr(e)},or={},ar=or,ir=ft,sr=Tt,lr=function(e){return sr(e)?e:void 0},cr=function(e,t){return arguments.length<2?lr(ar[e])||lr(ir[e]):ar[e]&&ar[e][t]||ir[e]&&ir[e][t]},pr=Ot({}.isPrototypeOf),dr=cr("navigator","userAgent")||"",ur=ft,hr=dr,fr=ur.process,mr=ur.Deno,yr=fr&&fr.versions||mr&&mr.version,gr=yr&&yr.v8;gr&&(Nt=(Bt=gr.split("."))[0]>0&&Bt[0]<4?1:+(Bt[0]+Bt[1])),!Nt&&hr&&(!(Bt=hr.match(/Edge\/(\d+)/))||Bt[1]>=74)&&(Bt=hr.match(/Chrome\/(\d+)/))&&(Nt=+Bt[1]);var vr=Nt,br=vr,xr=mt,wr=!!Object.getOwnPropertySymbols&&!xr((function(){var e=Symbol();return!String(e)||!(Object(e)instanceof Symbol)||!Symbol.sham&&br&&br<41})),$r=wr&&!Symbol.sham&&"symbol"==typeof Symbol.iterator,kr=cr,Sr=Tt,Ar=pr,Er=$r,Or=ft.Object,Tr=Er?function(e){return"symbol"==typeof e}:function(e){var t=kr("Symbol");return Sr(t)&&Ar(t.prototype,Or(e))},Cr=ft.String,jr=function(e){try{return Cr(e)}catch(e){return"Object"}},Ir=Tt,_r=jr,Pr=ft.TypeError,Rr=function(e){if(Ir(e))return e;throw Pr(_r(e)+" is not a function")},Lr=Rr,Fr=function(e,t){var r=e[t];return null==r?void 0:Lr(r)},Dr=Pt,Br=Tt,Nr=nr,qr=ft.TypeError,Ur={exports:{}},zr=ft,Mr=Object.defineProperty,Hr=ft.i||function(e,t){try{Mr(zr,e,{value:t,configurable:!0,writable:!0})}catch(r){zr[e]=t}return t}("__core-js_shared__",{}),Wr=Hr;(Ur.exports=function(e,t){return Wr[e]||(Wr[e]=void 0!==t?t:{})})("versions",[]).push({version:"3.21.1",mode:"pure",copyright:"© 2014-2022 Denis Pushkarev (zloirock.ru)",license:"https://github.com/zloirock/core-js/blob/v3.21.1/LICENSE",source:"https://github.com/zloirock/core-js"});var Vr=Qt,Gr=ft.Object,Kr=function(e){return Gr(Vr(e))},Jr=Kr,Yr=Ot({}.hasOwnProperty),Zr=Object.hasOwn||function(e,t){return Yr(Jr(e),t)},Qr=Ot,Xr=0,en=Math.random(),tn=Qr(1..toString),rn=function(e){return"Symbol("+(void 0===e?"":e)+")_"+tn(++Xr+en,36)},nn=ft,on=Ur.exports,an=Zr,sn=rn,ln=wr,cn=$r,pn=on("wks"),dn=nn.Symbol,un=dn&&dn.for,hn=cn?dn:dn&&dn.withoutSetter||sn,fn=function(e){if(!an(pn,e)||!ln&&"string"!=typeof pn[e]){var t="Symbol."+e;ln&&an(dn,e)?pn[e]=dn[e]:pn[e]=cn&&un?un(t):hn(t)}return pn[e]},mn=Pt,yn=nr,gn=Tr,vn=Fr,bn=fn,xn=ft.TypeError,wn=bn("toPrimitive"),$n=function(e,t){if(!yn(e)||gn(e))return e;var r,n=vn(e,wn);if(n){if(void 0===t&&(t="default"),r=mn(n,e,t),!yn(r)||gn(r))return r;throw xn("Can't convert object to primitive value")}return void 0===t&&(t="number"),function(e,t){var r,n;if("string"===t&&Br(r=e.toString)&&!Nr(n=Dr(r,e)))return n;if(Br(r=e.valueOf)&&!Nr(n=Dr(r,e)))return n;if("string"!==t&&Br(r=e.toString)&&!Nr(n=Dr(r,e)))return n;throw qr("Can't convert object to primitive value")}(e,t)},kn=Tr,Sn=function(e){var t=$n(e,"string");return kn(t)?t:t+""},An=nr,En=ft.document,On=An(En)&&An(En.createElement),Tn=function(e){return On?En.createElement(e):{}},Cn=Tn,jn=!jt&&!mt((function(){return 7!=Object.defineProperty(Cn("div"),"a",{get:function(){return 7}}).a})),In=jt,_n=Pt,Pn=Rt,Rn=qt,Ln=tr,Fn=Sn,Dn=Zr,Bn=jn,Nn=Object.getOwnPropertyDescriptor;Ct.f=In?Nn:function(e,t){if(e=Ln(e),t=Fn(t),Bn)try{return Nn(e,t)}catch(e){}if(Dn(e,t))return Rn(!_n(Pn.f,e,t),e[t])};var qn=mt,Un=Tt,zn=/#|\.prototype\./,Mn=function(e,t){var r=Wn[Hn(e)];return r==Gn||r!=Vn&&(Un(t)?qn(t):!!t)},Hn=Mn.normalize=function(e){return String(e).replace(zn,".").toLowerCase()},Wn=Mn.data={},Vn=Mn.NATIVE="N",Gn=Mn.POLYFILL="P",Kn=Mn,Jn=Rr,Yn=yt,Zn=Ot(Ot.bind),Qn=function(e,t){return Jn(e),void 0===t?e:Yn?Zn(e,t):function(){return e.apply(t,arguments)}},Xn={},eo=jt&&mt((function(){return 42!=Object.defineProperty((function(){}),"prototype",{value:42,writable:!1}).prototype})),to=ft,ro=nr,no=to.String,oo=to.TypeError,ao=function(e){if(ro(e))return e;throw oo(no(e)+" is not an object")},io=jt,so=jn,lo=eo,co=ao,po=Sn,uo=ft.TypeError,ho=Object.defineProperty,fo=Object.getOwnPropertyDescriptor;Xn.f=io?lo?function(e,t,r){if(co(e),t=po(t),co(r),"function"==typeof e&&"prototype"===t&&"value"in r&&"writable"in r&&!r.writable){var n=fo(e,t);n&&n.writable&&(e[t]=r.value,r={configurable:"configurable"in r?r.configurable:n.configurable,enumerable:"enumerable"in r?r.enumerable:n.enumerable,writable:!1})}return ho(e,t,r)}:ho:function(e,t,r){if(co(e),t=po(t),co(r),so)try{return ho(e,t,r)}catch(e){}if("get"in r||"set"in r)throw uo("Accessors not supported");return"value"in r&&(e[t]=r.value),e};var mo=Xn,yo=qt,go=jt?function(e,t,r){return mo.f(e,t,yo(1,r))}:function(e,t,r){return e[t]=r,e},vo=ft,bo=wt,xo=Ot,wo=Tt,$o=Ct.f,ko=Kn,So=or,Ao=Qn,Eo=go,Oo=Zr,To=function(e){var t=function(r,n,o){if(this instanceof t){switch(arguments.length){case 0:return new e;case 1:return new e(r);case 2:return new e(r,n)}return new e(r,n,o)}return bo(e,this,arguments)};return t.prototype=e.prototype,t},Co=function(e,t){var r,n,o,a,i,s,l,c,p=e.target,d=e.global,u=e.stat,h=e.proto,f=d?vo:u?vo[p]:(vo[p]||{}).prototype,m=d?So:So[p]||Eo(So,p,{})[p],y=m.prototype;for(o in t)r=!ko(d?o:p+(u?".":"#")+o,e.forced)&&f&&Oo(f,o),i=m[o],r&&(s=e.noTargetGet?(c=$o(f,o))&&c.value:f[o]),a=r&&s?s:t[o],r&&typeof i==typeof a||(l=e.bind&&r?Ao(a,vo):e.wrap&&r?To(a):h&&wo(a)?xo(a):a,(e.sham||a&&a.sham||i&&i.sham)&&Eo(l,"sham",!0),Eo(m,o,l),h&&(Oo(So,n=p+"Prototype")||Eo(So,n,{}),Eo(So[n],o,a),e.real&&y&&!y[o]&&Eo(y,o,a)))},jo=Math.ceil,Io=Math.floor,_o=function(e){var t=+e;return t!=t||0===t?0:(t>0?Io:jo)(t)},Po=_o,Ro=Math.max,Lo=Math.min,Fo=function(e,t){var r=Po(e);return r<0?Ro(r+t,0):Lo(r,t)},Do=_o,Bo=Math.min,No=function(e){return e>0?Bo(Do(e),9007199254740991):0},qo=No,Uo=function(e){return qo(e.length)},zo=tr,Mo=Fo,Ho=Uo,Wo=function(e){return function(t,r,n){var o,a=zo(t),i=Ho(a),s=Mo(n,i);if(e&&r!=r){for(;i>s;)if((o=a[s++])!=o)return!0}else for(;i>s;s++)if((e||s in a)&&a[s]===r)return e||s||0;return!e&&-1}},Vo={includes:Wo(!0),indexOf:Wo(!1)},Go={},Ko=Zr,Jo=tr,Yo=Vo.indexOf,Zo=Go,Qo=Ot([].push),Xo=function(e,t){var r,n=Jo(e),o=0,a=[];for(r in n)!Ko(Zo,r)&&Ko(n,r)&&Qo(a,r);for(;t.length>o;)Ko(n,r=t[o++])&&(~Yo(a,r)||Qo(a,r));return a},ea=["constructor","hasOwnProperty","isPrototypeOf","propertyIsEnumerable","toLocaleString","toString","valueOf"],ta=Xo,ra=ea,na=Object.keys||function(e){return ta(e,ra)},oa=Kr,aa=na;Co({target:"Object",stat:!0,forced:mt((function(){aa(1)}))},{keys:function(e){return aa(oa(e))}});var ia=or.Object.keys;const sa=ut({exports:{}}.exports=ia);var la=Ht,ca=Array.isArray||function(e){return"Array"==la(e)},pa={};pa[fn("toStringTag")]="z";var da="[object z]"===String(pa),ua=ft,ha=da,fa=Tt,ma=Ht,ya=fn("toStringTag"),ga=ua.Object,va="Arguments"==ma(function(){return arguments}()),ba=ha?ma:function(e){var t,r,n;return void 0===e?"Undefined":null===e?"Null":"string"==typeof(r=function(e,t){try{return e[t]}catch(e){}}(t=ga(e),ya))?r:va?ma(t):"Object"==(n=ma(t))&&fa(t.callee)?"Arguments":n},xa=ba,wa=ft.String,$a=function(e){if("Symbol"===xa(e))throw TypeError("Cannot convert a Symbol value to a string");return wa(e)},ka={},Sa=jt,Aa=eo,Ea=Xn,Oa=ao,Ta=tr,Ca=na;ka.f=Sa&&!Aa?Object.defineProperties:function(e,t){Oa(e);for(var r,n=Ta(t),o=Ca(t),a=o.length,i=0;a>i;)Ea.f(e,r=o[i++],n[r]);return e};var ja,Ia=cr("document","documentElement"),_a=Ur.exports,Pa=rn,Ra=_a("keys"),La=function(e){return Ra[e]||(Ra[e]=Pa(e))},Fa=ao,Da=ka,Ba=ea,Na=Go,qa=Ia,Ua=Tn,za=La("IE_PROTO"),Ma=function(){},Ha=function(e){return" + + + + + + + \ No newline at end of file diff --git a/public/redoc-try.html b/public/redoc-try.html new file mode 100644 index 000000000..5884bfcdf --- /dev/null +++ b/public/redoc-try.html @@ -0,0 +1,11 @@ + +
    + + + + \ No newline at end of file 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..6b847f944 --- /dev/null +++ b/src/@types/api-schema-builder.d.ts @@ -0,0 +1 @@ +declare module 'api-schema-builder' diff --git a/src/api/course/index.ts b/src/api/course/index.ts new file mode 100644 index 000000000..632bc4ef3 --- /dev/null +++ b/src/api/course/index.ts @@ -0,0 +1,885 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { createDebug } from '../../debug' +const debug = createDebug('signalk-server:api:course') + +import { IRouter, Request, Response } from 'express' +import _ from 'lodash' +import path from 'path' +import { SignalKMessageHub, WithConfig } from '../../app' +import { WithSecurityStrategy } from '../../security' + +import { + GeoJsonPoint, + Position, + Route, + SignalKResourceType +} 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' +import { ResourcesApi } from '../resources' +import { Delta, SKVersion } from '../../types' + +const COURSE_API_SCHEMA = buildSchemaSync(courseOpenApi) + +const SIGNALK_API_PATH = `/signalk/v2/api` +const COURSE_API_PATH = `${SIGNALK_API_PATH}/vessels/self/navigation/course` + +export const COURSE_API_V2_DELTA_COUNT = 13 +export const COURSE_API_V1_DELTA_COUNT = 8 +export const COURSE_API_INITIAL_DELTA_COUNT = + COURSE_API_V1_DELTA_COUNT * 2 + COURSE_API_V2_DELTA_COUNT + +interface CourseApplication + extends IRouter, + WithConfig, + WithSecurityStrategy, + SignalKMessageHub {} + +interface DestinationBase { + href?: string +} +interface Destination extends DestinationBase { + position?: Position + type?: string +} +interface ActiveRoute extends DestinationBase { + href: string //ActiveRoute always has href + pointIndex: number + pointTotal: number + reverse: boolean + name: string + waypoints?: any[] +} + +interface CourseInfo { + startTime: string | null + targetArrivalTime: string | null + arrivalCircle: number + activeRoute: ActiveRoute | null + nextPoint: { + href?: string | null + type: string | null + position: Position | null + } | null + previousPoint: { + href?: string | null + type: string | null + position: Position | null + } | null +} + +export class CourseApi { + private courseInfo: CourseInfo = { + startTime: null, + targetArrivalTime: null, + arrivalCircle: 0, + activeRoute: null, + nextPoint: null, + previousPoint: null + } + + private store: Store + + constructor( + private server: CourseApplication, + private resourcesApi: ResourcesApi + ) { + this.store = new Store( + path.join(server.config.configPath, 'serverstate/course') + ) + } + + async start() { + // eslint-disable-next-line no-async-promise-executor + return new Promise(async (resolve) => { + this.initCourseRoutes() + + let storeData + try { + storeData = await this.store.read() + debug('Found persisted course data') + this.courseInfo = this.validateCourseInfo(storeData) + } catch (error) { + debug('No persisted course data (using default)') + } + debug(this.courseInfo) + if (storeData) { + 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) + } + ) + + // course metadata + this.server.get( + `${COURSE_API_PATH}/arrivalCircle/meta`, + async (req: Request, res: Response) => { + debug(`** GET ${COURSE_API_PATH}/arrivalCircle/meta`) + res.json({ + arrivalCircle: { + description: + 'The circle which indicates arrival when vessel position is within its radius.', + units: 'm' + } + }) + } + ) + + 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.arrivalCircle = req.body.value + this.emitCourseInfo(false, 'arrivalCircle') + 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) => { + debug(`** PUT ${COURSE_API_PATH}/restart`) + if (!this.updateAllowed(req)) { + res.status(403).json(Responses.unauthorised) + return + } + if (!this.courseInfo.nextPoint) { + 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, + type: 'VesselPosition' + } + this.emitCourseInfo(false, 'previousPoint') + 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}/targetArrivalTime`, + async (req: Request, res: Response) => { + debug(`** PUT ${COURSE_API_PATH}/targetArrivalTime`) + if (!this.updateAllowed(req)) { + res.status(403).json(Responses.unauthorised) + return + } + if (req.body.value === null || this.isValidIsoTime(req.body.value)) { + this.courseInfo.targetArrivalTime = req.body.value + this.emitCourseInfo(false, 'targetArrivalTime') + res.status(200).json(Responses.ok) + } else { + res.status(400).json(Responses.invalid) + } + } + ) + + // clear / cancel course + this.server.delete( + `${COURSE_API_PATH}`, + async (req: Request, res: Response) => { + debug(`** DELETE ${COURSE_API_PATH}`) + if (!this.updateAllowed(req)) { + res.status(403).json(Responses.unauthorised) + return + } + this.clearDestination() + this.emitCourseInfo() + res.status(200).json(Responses.ok) + } + ) + + // 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 + } + + 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 + }) + } + } + ) + + // 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 + } + try { + const result = await this.activateRoute(req.body) + console.log(this.courseInfo) + 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 + }) + } + } + ) + + 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) { + 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 this.courseInfo.activeRoute.pointIndex === 'number') { + if (!req.body.value || typeof req.body.value !== 'number') { + 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 + ) + } + 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 as number, + this.courseInfo.activeRoute.reverse + ), + type: 'RoutePoint' + } + + // set previousPoint + if (this.courseInfo.activeRoute.pointIndex === 0) { + try { + const position: any = this.getVesselPosition() + if (position && position.value) { + this.courseInfo.previousPoint = { + position: position.value, + 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 as number) - 1, + this.courseInfo.activeRoute.reverse + ), + type: 'RoutePoint' + } + } + this.emitCourseInfo() + res.status(200).json(Responses.ok) + } + ) + } + + private calcReversedIndex(activeRoute: ActiveRoute): number { + return ( + (activeRoute.pointTotal as number) - + 1 - + (activeRoute.pointIndex as number) + ) + } + + private async activateRoute(route: { + href?: string + reverse?: boolean + pointIndex?: number + arrivalCircle?: number + }): Promise { + const { href, reverse } = route + let rte: any + + if (href) { + rte = await this.getRoute(href) + if (!rte) { + throw new Error( + `** Could not retrieve route information for ${route.href}` + ) + } + if (!Array.isArray(rte.feature?.geometry?.coordinates)) { + throw new Error(`Invalid route coordinate data! (${route.href})`) + } + } else { + throw new Error('Route information not supplied!') + } + + const newCourse: CourseInfo = { ...this.courseInfo } + const pointIndex = this.parsePointIndex(route.pointIndex as number, rte) + const activeRoute = { + href, + name: rte.name, + reverse: !!reverse, + pointIndex, + pointTotal: rte.feature.geometry.coordinates.length + } + newCourse.activeRoute = activeRoute + newCourse.nextPoint = { + type: `RoutePoint`, + position: this.getRoutePoint(rte, pointIndex, !!reverse) + } + newCourse.startTime = new Date().toISOString() + + if (this.isValidArrivalCircle(route.arrivalCircle as number)) { + newCourse.arrivalCircle = route.arrivalCircle as number + } + + // set previousPoint + if (activeRoute.pointIndex === 0) { + try { + const position: any = this.getVesselPosition() + if (position && position.value) { + newCourse.previousPoint = { + position: position.value, + type: 'VesselPosition' + } + } else { + throw new Error(`Error: Unable to retrieve vessel position!`) + } + } catch (err) { + throw new Error(`Error: Unable to retrieve vessel position!`) + } + } else { + newCourse.previousPoint = { + position: this.getRoutePoint( + rte, + activeRoute.pointIndex - 1, + activeRoute.reverse + ), + type: 'RoutePoint' + } + } + + this.courseInfo = newCourse + return true + } + + private async setDestination( + dest: Destination & { arrivalCircle?: number } + ): Promise { + const newCourse: CourseInfo = { ...this.courseInfo } + + newCourse.startTime = new Date().toISOString() + + if (this.isValidArrivalCircle(dest.arrivalCircle)) { + newCourse.arrivalCircle = dest.arrivalCircle as number + } + + 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.resourcesApi.getResource( + typedHref.type, + typedHref.id + )) as any + if (isValidCoordinate(r.feature.geometry.coordinates)) { + newCourse.nextPoint = { + position: { + latitude: r.feature.geometry.coordinates[1], + longitude: r.feature.geometry.coordinates[0] + }, + href: dest.href, + type: r.type ?? 'Waypoint' + } + newCourse.activeRoute = null + } else { + throw new Error(`Invalid waypoint coordinate data! (${dest.href})`) + } + } catch (err) { + throw new Error(`Error retrieving and validating ${dest.href}`) + } + } else { + throw new Error(`Invalid href! (${dest.href})`) + } + } else if (dest.position) { + if (isValidCoordinate(dest.position)) { + newCourse.nextPoint = { + position: dest.position, + type: 'Location' + } + } else { + throw new Error(`Error: position is not valid`) + } + } else { + throw new Error(`Destination not provided!`) + } + + // clear activeRoute + newCourse.activeRoute = null + + // set previousPoint + try { + const position: any = this.getVesselPosition() + if (position && position.value) { + newCourse.previousPoint = { + position: position.value, + type: 'VesselPosition' + } + } else { + throw new Error( + `Error: navigation.position.value is undefined! (${position})` + ) + } + } catch (err) { + throw new Error(`Error: Unable to retrieve vessel position!`) + } + + this.courseInfo = newCourse + return true + } + + private clearDestination() { + this.courseInfo.startTime = null + this.courseInfo.targetArrivalTime = null + this.courseInfo.activeRoute = null + this.courseInfo.nextPoint = null + this.courseInfo.previousPoint = null + } + + private isValidArrivalCircle(value: number | undefined): boolean { + 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 + } + 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: SignalKResourceType; 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] as SignalKResourceType, + id: ref[2] + } + } + + private getRoutePoint(rte: any, index: number, reverse: boolean | null) { + 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 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) { + try { + return (await this.resourcesApi.getResource(h.type, h.id)) as + | Route + | undefined + } catch (err) { + debug(`** Unable to fetch resource: ${h.type}, ${h.id}`) + return undefined + } + } else { + debug(`** Unable to parse href: ${href}`) + return undefined + } + } + + private buildDeltaMsg(paths: string[]): any { + const values: Array<{ path: string; value: any }> = [] + const navPath = 'navigation.course' + + debug(this.courseInfo) + + if ( + paths.length === 0 || + (paths && (paths.includes('activeRoute') || paths.includes('nextPoint'))) + ) { + values.push({ + path: `${navPath}.startTime`, + value: this.courseInfo.startTime + }) + } + + if (paths.length === 0 || (paths && paths.includes('targetArrivalTime'))) { + values.push({ + path: `${navPath}.targetArrivalTime`, + value: this.courseInfo.targetArrivalTime + }) + } + + if (paths.length === 0 || (paths && paths.includes('activeRoute'))) { + values.push({ + path: `${navPath}.activeRoute`, + value: this.courseInfo.activeRoute + }) + } + + if (paths.length === 0 || (paths && paths.includes('nextPoint'))) { + values.push({ + path: `${navPath}.nextPoint`, + value: this.courseInfo.nextPoint + }) + } + + if (paths.length === 0 || (paths && paths.includes('arrivalCircle'))) { + values.push({ + path: `${navPath}.arrivalCircle`, + value: this.courseInfo.arrivalCircle + }) + } + + if (paths.length === 0 || (paths && paths.includes('previousPoint'))) { + values.push({ + path: `${navPath}.previousPoint`, + value: this.courseInfo.previousPoint + }) + } + + return { + updates: [ + { + values + } + ] + } + } + + private buildV1DeltaMsg(paths: string[]): Delta { + const values: Array<{ path: string; value: any }> = [] + const navGC = 'navigation.courseGreatCircle' + const navRL = 'navigation.courseRhumbline' + + if ( + this.courseInfo.activeRoute && + (paths.length === 0 || (paths && paths.includes('activeRoute'))) + ) { + values.push({ + path: `${navGC}.activeRoute.href`, + value: this.courseInfo.activeRoute.href + }) + values.push({ + path: `${navRL}.activeRoute.href`, + value: this.courseInfo.activeRoute.href + }) + + values.push({ + path: `${navGC}.activeRoute.startTime`, + value: this.courseInfo.startTime + }) + values.push({ + path: `${navRL}.activeRoute.startTime`, + value: this.courseInfo.startTime + }) + } + if ( + this.courseInfo.nextPoint && + (paths.length === 0 || (paths && paths.includes('nextPoint'))) + ) { + values.push({ + path: `${navGC}.nextPoint.value.href`, + value: this.courseInfo.nextPoint.href ?? null + }) + values.push({ + path: `${navRL}.nextPoint.value.href`, + value: this.courseInfo.nextPoint.href ?? null + }) + + values.push({ + path: `${navGC}.nextPoint.value.type`, + value: this.courseInfo.nextPoint.type + }) + values.push({ + path: `${navRL}.nextPoint.value.type`, + value: this.courseInfo.nextPoint.type + }) + + values.push({ + path: `${navGC}.nextPoint.position`, + value: this.courseInfo.nextPoint.position + }) + values.push({ + path: `${navRL}.nextPoint.position`, + value: this.courseInfo.nextPoint.position + }) + } + if (paths.length === 0 || (paths && paths.includes('arrivalCircle'))) { + values.push({ + path: `${navGC}.nextPoint.arrivalCircle`, + value: this.courseInfo.arrivalCircle + }) + values.push({ + path: `${navRL}.nextPoint.arrivalCircle`, + value: this.courseInfo.arrivalCircle + }) + } + if ( + this.courseInfo.previousPoint && + (paths.length === 0 || (paths && paths.includes('previousPoint'))) + ) { + values.push({ + path: `${navGC}.previousPoint.position`, + value: this.courseInfo.previousPoint.position + }) + values.push({ + path: `${navRL}.previousPoint.position`, + value: this.courseInfo.previousPoint.position + }) + + values.push({ + path: `${navGC}.previousPoint.value.type`, + value: this.courseInfo.previousPoint.type + }) + values.push({ + path: `${navRL}.previousPoint.value.type`, + value: this.courseInfo.previousPoint.type + }) + } + + return { + updates: [ + { + values + } + ] + } + } + + private emitCourseInfo(noSave = false, ...paths: string[]) { + this.server.handleMessage( + 'courseApi', + this.buildV1DeltaMsg(paths), + SKVersion.v1 + ) + this.server.handleMessage( + 'courseApi', + this.buildDeltaMsg(paths), + SKVersion.v2 + ) + 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..d7d893b87 --- /dev/null +++ b/src/api/course/openApi.json @@ -0,0 +1,713 @@ +{ + "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": "/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": { + "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/[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/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/[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/ac3a3b2d-07e8-4f25-92bc-98e7c92f7f1a" + } + } + }, + "ActiveRouteModel": { + "type": "object", + "required": ["href", "pointIndex", "pointTotal", "reverse"], + "properties": { + "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, + "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." + } + } + }, + "PointModel": { + "anyOf": [ + { + "$ref": "#/components/schemas/HrefWaypointAttribute" + }, + { + "$ref": "#/components/schemas/PointTypeAttribute" + } + ], + "allOf": [ + { + "$ref": "#/components/schemas/PositionAttribute" + } + ] + }, + "PositionAttribute": { + "type": "object", + "required": ["position"], + "properties": { + "position": { + "description": "Location coordinates.", + "example": { + "latitude": 65.4567, + "longitude": 3.3452 + }, + "allOf": [ + { + "$ref": "#/components/schemas/SignalKPosition" + } + ] + } + } + }, + "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": { + "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": ["GreatCircle", "Rhumbline"], + "default": "GreatCircle", + "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": { + "$ref": "#/components/schemas/IsoTime", + "description": "The estimated time of arrival at nextPoint position." + }, + "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 + }, + "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.", + "properties": { + "distance": { + "type": "number", + "minimum": 0, + "description": "The distance in meters between the vessel's present position and the start point.", + "example": 10157 + } + } + } + } + } + }, + "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": "Course response", + "type": "object", + "required": ["activeRoute", "nextPoint", "previousPoint"], + "properties": { + "activeRoute": { + "anyOf": [ + { + "$ref": "#/components/schemas/ActiveRouteModel" + } + ] + }, + "nextPoint": { + "anyOf": [ + { + "$ref": "#/components/schemas/PointModel" + } + ] + }, + "previousPoint": { + "anyOf": [ + { + "$ref": "#/components/schemas/PointModel" + } + ] + }, + "startTime": { + "$ref": "#/components/schemas/IsoTime", + "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." + }, + "arrivalCircle": { + "$ref": "#/components/schemas/ArrivalCircle" + } + } + } + } + } + } + }, + "securitySchemes": { + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT" + }, + "cookieAuth": { + "type": "apiKey", + "in": "cookie", + "name": "JAUTHENTICATION" + } + } + }, + "security": [{ "cookieAuth": [] }, { "bearerAuth": [] }], + "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" + } + } + }, + "delete": { + "tags": ["course"], + "summary": "Cancel / clear course.", + "description": "Clear all course information.", + "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/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/targetArrivalTime": { + "put": { + "tags": ["course"], + "summary": "Set target arrival time.", + "description": "Sets the desired time to arrive at the destination. Used to calculate targetSpeed.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["value"], + "properties": { + "value": { + "$ref": "#/components/schemas/IsoTime" + } + } + } + } + } + }, + "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" + } + } + } + }, + "/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" + } + } + } + }, + "/course/activeRoute/nextPoint": { + "put": { + "tags": ["activeRoute"], + "summary": "Set next point in route.", + "description": "Sets nextPoint / previousPoint.", + "requestBody": { + "description": "Destination details.", + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["value"], + "properties": { + "value": { + "type": "number", + "description": "Index of point in route (-ive = previous)", + "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/calcValues": { + "get": { + "tags": ["calculations"], + "summary": "Course calculated values.", + "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/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/index.ts b/src/api/index.ts new file mode 100644 index 000000000..2b781c9a1 --- /dev/null +++ b/src/api/index.ts @@ -0,0 +1,47 @@ +import { IRouter } from 'express' +import { SignalKMessageHub, WithConfig } from '../app' +import { WithSecurityStrategy } from '../security' +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.' + } +} + +export const startApis = ( + app: SignalKMessageHub & WithSecurityStrategy & IRouter & WithConfig +) => { + const resourcesApi = new ResourcesApi(app) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(app as any).resourcesApi = resourcesApi + const courseApi = new CourseApi(app, resourcesApi) + Promise.all([resourcesApi.start(), courseApi.start()]) +} diff --git a/src/api/notifications/openApi.json b/src/api/notifications/openApi.json new file mode 100644 index 000000000..a28e33d58 --- /dev/null +++ b/src/api/notifications/openApi.json @@ -0,0 +1,331 @@ +{ + "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": "/signalk/v1/api/vessels/self/notifications" + } + ], + "tags": [ + { + "name": "notifications", + "description": "Root path" + }, + { + "name": "special", + "description": "Special Alarms" + }, + { + "name": "course", + "description": "Course 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" + } + } + }, + "Notifications": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/Alarm" + } + } + }, + "responses": { + "ListResponse": { + "description": "Collection of notifications", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Notifications" + } + } + } + }, + "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": { + "/": { + "get": { + "tags": ["notifications"], + "summary": "Notifications endpoint.", + "description": "Root path for notifications.", + "responses": { + "200": { + "$ref": "#/components/responses/ListResponse" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + }, + "/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" + } + } + } + } + } +} diff --git a/src/api/notifications/openApi.ts b/src/api/notifications/openApi.ts new file mode 100644 index 000000000..df66a61ca --- /dev/null +++ b/src/api/notifications/openApi.ts @@ -0,0 +1,7 @@ +import notificationsApiDoc from './openApi.json' + +export const notificationsApiRecord = { + name: 'notifications', + path: '/signalk/v1/api/vessels/self/notifications', + apiDoc: notificationsApiDoc +} diff --git a/src/api/resources/index.ts b/src/api/resources/index.ts new file mode 100644 index 000000000..666a6e905 --- /dev/null +++ b/src/api/resources/index.ts @@ -0,0 +1,733 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { createDebug } from '../../debug' +const debug = createDebug('signalk-server:api:resources') + +import { + isSignalKResourceType, + ResourceProvider, + ResourceProviderMethods, + SignalKResourceType +} from '@signalk/server-api' + +import { IRouter, NextFunction, Request, Response } from 'express' +import { v4 as uuidv4 } from 'uuid' +import { WithSecurityStrategy } from '../../security' + +import { Responses } from '../' +import { validate } from './validate' +import { SignalKMessageHub } from '../../app' +import { Delta, SKVersion } from '../../types' + +export const RESOURCES_API_PATH = `/signalk/v2/api/resources` + +export const skUuid = () => `${uuidv4()}` + +interface ResourceApplication + extends IRouter, + WithSecurityStrategy, + SignalKMessageHub {} + +export class ResourcesApi { + private resProvider: { [key: string]: Map } = + {} + + constructor(app: ResourceApplication) { + this.initResourceRoutes(app) + } + + async start() { + return Promise.resolve() + } + + register(pluginId: string, provider: ResourceProvider) { + debug(`** Registering ${provider.type} provider => ${pluginId} `) + + if (!provider) { + throw new Error(`Error registering provider ${pluginId}!`) + } + if (!provider.type) { + throw new Error(`Invalid ResourceProvider.type value!`) + } + 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 { + if (!this.resProvider[provider.type]) { + this.resProvider[provider.type] = new Map() + } + this.resProvider[provider.type].set(pluginId, provider.methods) + } + debug(this.resProvider[provider.type]) + } + + unRegister(pluginId: string) { + if (!pluginId) { + return + } + debug(`** Un-registering ${pluginId} plugin as a resource provider....`) + for (const resourceType in this.resProvider) { + if (this.resProvider[resourceType].has(pluginId)) { + debug(`** Un-registering ${pluginId} as ${resourceType} provider....`) + this.resProvider[resourceType].delete(pluginId) + } + } + debug(this.resProvider) + } + + async getResource( + resType: SignalKResourceType, + resId: string, + providerId?: string + ) { + debug(`** getResource(${resType}, ${resId})`) + + const provider = this.checkForProvider(resType, providerId) + if (!provider) { + return Promise.reject(new Error(`No provider for ${resType}`)) + } + return this.getFromAll(resType, resId) + } + + async listResources( + resType: SignalKResourceType, + params: { [key: string]: any }, + providerId?: string + ) { + debug(`** listResources(${resType}, ${JSON.stringify(params)})`) + + const provider = this.checkForProvider(resType, providerId) + if (!provider) { + return Promise.reject(new Error(`No provider for ${resType}`)) + } + return this.listFromAll(resType, params) + } + + async setResource( + resType: SignalKResourceType, + resId: string, + data: { [key: string]: any }, + providerId?: string + ) { + debug(`** setResource(${resType}, ${resId}, ${JSON.stringify(data)})`) + + 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) + } + + let provider: string | undefined = undefined + if (providerId) { + provider = this.checkForProvider(resType, providerId) + } else { + provider = await this.getProviderForResourceId(resType, resId) + } + if (provider) { + return this.resProvider[resType]?.get(provider)?.setResource(resId, data) + } else { + return Promise.reject(new Error(`No provider for ${resType}`)) + } + } + + async deleteResource( + resType: SignalKResourceType, + resId: string, + providerId?: string + ) { + debug(`** deleteResource(${resType}, ${resId})`) + + let provider: string | undefined = undefined + if (providerId) { + provider = this.checkForProvider(resType, providerId) + } else { + provider = await this.getProviderForResourceId(resType, resId) + } + if (provider) { + return this.resProvider[resType]?.get(provider)?.deleteResource(resId) + } else { + return Promise.reject(new Error(`No provider for ${resType}`)) + } + } + + private hasRegisteredProvider(resType: string): boolean { + const result = + this.resProvider[resType] && this.resProvider[resType].size !== 0 + ? true + : false + debug(`hasRegisteredProvider(${resType}).result = ${result}`) + return result + } + + // validates provider Id for a resourceType + private checkForProvider( + resType: SignalKResourceType, + providerId?: string + ): string | undefined { + debug(`** checkForProvider(${resType}, ${providerId})`) + let result: string | undefined = undefined + if (providerId) { + result = this.resProvider[resType].has(providerId) + ? providerId + : undefined + } else { + result = this.resProvider[resType].keys().next().value + } + debug(`** checkForProvider().result = ${result}`) + return result + } + + // retrieve matching resources from ALL providers + private async listFromAll(resType: string, params: { [key: string]: any }) { + debug(`listFromAll(${resType}, ${JSON.stringify(params)})`) + + const result = {} + if (!this.resProvider[resType]) { + return result + } + const req: Promise[] = [] + this.resProvider[resType].forEach((v) => { + req.push(v.listResources(params)) + }) + + const resp = await Promise.allSettled(req) + resp.forEach((r) => { + if (r.status === 'fulfilled') { + Object.assign(result, r.value) + } + }) + return result + } + + // query ALL providers for supplied resource id + private async getFromAll(resType: string, resId: string, property?: string) { + debug(`getFromAll(${resType}, ${resId})`) + + const result = {} + if (!this.resProvider[resType]) { + return result + } + const req: Promise[] = [] + this.resProvider[resType].forEach((id) => { + req.push(id.getResource(resId, property)) + }) + + const resp = await Promise.allSettled(req) + resp.forEach((r) => { + if (r.status === 'fulfilled') { + Object.assign(result, r.value) + } + }) + return result + } + + // return providerId for supplied resource id + private async getProviderForResourceId( + resType: string, + resId: string, + fallbackToDefault?: boolean + ): Promise { + debug( + `getProviderForResourceId(${resType}, ${resId}, ${fallbackToDefault})` + ) + + let result: string | undefined = undefined + + if (!this.resProvider[resType]) { + return result + } + const req: Promise[] = [] + const idList: string[] = [] + this.resProvider[resType].forEach((v, k) => { + idList.push(k) + req.push(v.getResource(resId)) + }) + + const resp = await Promise.allSettled(req) + let idx = 0 + resp.forEach((r) => { + if (r.status === 'fulfilled') { + result = !result ? idList[idx] : result + } + idx++ + }) + + if (!result && fallbackToDefault) { + result = this.resProvider[resType].keys().next().value + } + debug(`getProviderForResourceId().result = ${result}`) + return result + } + + 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.hasRegisteredProvider(req.params.resourceType)) { + next() + return + } + + try { + if (req.query.provider) { + const provider = this.checkForProvider( + req.params.resourceType as SignalKResourceType, + req.query.provider ? (req.query.provider as string) : undefined + ) + if (!provider) { + debug('** No provider found... calling next()...') + next() + return + } + const retVal = await this.resProvider[req.params.resourceType] + ?.get(provider) + ?.getResource(req.params.resourceId) + res.json(retVal) + } else { + const retVal = await this.getFromAll( + req.params.resourceType, + 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 specific resource property + server.get( + `${RESOURCES_API_PATH}/:resourceType/:resourceId/*`, + async (req: Request, res: Response, next: NextFunction) => { + debug(`** GET ${RESOURCES_API_PATH}/:resourceType/:resourceId/*`) + + if (!this.hasRegisteredProvider(req.params.resourceType)) { + next() + return + } + + try { + const property = req.params['0'] + ? req.params['0'].split('/').join('.') + : undefined + + if (req.query.provider) { + const provider = this.checkForProvider( + req.params.resourceType as SignalKResourceType, + req.query.provider ? (req.query.provider as string) : undefined + ) + if (!provider) { + debug('** No provider found... calling next()...') + next() + return + } + const retVal = await this.resProvider[req.params.resourceType] + ?.get(provider) + ?.getResource(req.params.resourceId, property) + res.json(retVal) + } else { + const retVal = await this.getFromAll( + req.params.resourceType, + req.params.resourceId, + property + ) + 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.hasRegisteredProvider(req.params.resourceType)) { + 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 as Error).message + }) + return + } + } + + try { + if (req.query.provider) { + const provider = this.checkForProvider( + req.params.resourceType as SignalKResourceType, + req.query.provider ? (req.query.provider as string) : undefined + ) + if (!provider) { + debug('** No provider found... calling next()...') + next() + return + } + const retVal = await this.resProvider[req.params.resourceType] + ?.get(provider) + ?.listResources(parsedQuery) + res.json(retVal) + } else { + const retVal = await this.listFromAll( + req.params.resourceType, + 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.hasRegisteredProvider(req.params.resourceType)) { + next() + return + } + + const provider = this.checkForProvider( + req.params.resourceType as SignalKResourceType, + req.query.provider ? (req.query.provider as string) : undefined + ) + if (!provider) { + 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 as Error).message + }) + return + } + } + + let id: string + if (req.params.resourceType === 'charts') { + id = req.body.identifier + } else { + id = skUuid() + } + + try { + await this.resProvider[req.params.resourceType] + ?.get(provider) + ?.setResource(id, req.body) + + server.handleMessage( + provider as string, + this.buildDeltaMsg( + req.params.resourceType as SignalKResourceType, + id, + req.body + ), + SKVersion.v2 + ) + res.status(201).json({ + state: 'COMPLETED', + statusCode: 201, + id + }) + } catch (err) { + console.log(err) + res.status(400).json({ + state: 'FAILED', + statusCode: 400, + 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.hasRegisteredProvider(req.params.resourceType)) { + 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 as Error).message + }) + return + } + } + + try { + let provider: string | undefined = undefined + if (req.query.provider) { + provider = this.checkForProvider( + req.params.resourceType as SignalKResourceType, + req.query.provider ? (req.query.provider as string) : undefined + ) + } else { + provider = await this.getProviderForResourceId( + req.params.resourceType, + req.params.resourceId, + true + ) + } + if (!provider) { + debug('** No provider found... calling next()...') + next() + return + } + await this.resProvider[req.params.resourceType] + ?.get(provider) + ?.setResource(req.params.resourceId, req.body) + + server.handleMessage( + provider as string, + this.buildDeltaMsg( + req.params.resourceType as SignalKResourceType, + req.params.resourceId, + req.body + ), + SKVersion.v2 + ) + 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.hasRegisteredProvider(req.params.resourceType)) { + next() + return + } + + if (!updateAllowed(req)) { + res.status(403).json(Responses.unauthorised) + return + } + + try { + let provider: string | undefined = undefined + if (req.query.provider) { + provider = this.checkForProvider( + req.params.resourceType as SignalKResourceType, + req.query.provider ? (req.query.provider as string) : undefined + ) + } else { + provider = await this.getProviderForResourceId( + req.params.resourceType, + req.params.resourceId + ) + } + if (!provider) { + debug('** No provider found... calling next()...') + next() + return + } + + await this.resProvider[req.params.resourceType] + ?.get(provider) + ?.deleteResource(req.params.resourceId) + + server.handleMessage( + provider as string, + this.buildDeltaMsg( + req.params.resourceType as SignalKResourceType, + req.params.resourceId, + null + ), + SKVersion.v2 + ) + 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) { + // eslint-disable-next-line no-prototype-builtins + if (this.resProvider.hasOwnProperty(i)) { + resPaths[i] = { + description: `Path containing ${ + i.slice(-1) === 's' ? i.slice(0, i.length - 1) : i + } resources` + } + } + } + return resPaths + } + + private buildDeltaMsg( + resType: SignalKResourceType, + resid: string, + resValue: any + ): Delta { + 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..3b89bf773 --- /dev/null +++ b/src/api/resources/openApi.json @@ -0,0 +1,1535 @@ +{ + "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": [ + { + "url": "/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" + } + }, + "example": { + "type": "LineString", + "coordinates": [ + [24.897849923663273, 60.15202408867182], + [24.902023936156723, 60.05997020643639], + [24.67031474903851, 59.627962940557154], + [24.777473968139503, 59.449626524251926], + [24.758341055158922, 59.44208867188729] + ] + } + }, + "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": "[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": "ac3a3b2d-07e8-4f25-92bc-98e7c92f7f1a" + }, + "SignalKHref": { + "type": "string", + "pattern": "^/resources/(\\w*)/[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/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", + "example": "HSK to Old City Marina" + }, + "description": { + "type": "string", + "description": "A description of the route", + "example": "Helsinki to Tallinn via Helsinki & Tallinn lighthouses" + }, + "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 + } + } + } + } + }, + "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" + }, + "type": { + "type": "string", + "description": "The type of point (e.g. Waypoint, PoI, Race Mark, etc)" + }, + "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 + } + } + } + } + }, + "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 + } + } + } + } + }, + "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" + } + ] + }, + "TileLayerSource": { + "description": "Attributes to describe a chart resource.", + "type": "object", + "required": ["type"], + "properties": { + "type": { + "type": "string", + "description": "Source type of map data.", + "enum": ["tilelayer"], + "default": "tilelayer", + "example": "tilelayer" + }, + "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 + ] + }, + "format": { + "type": "string", + "description": "The file format of the tile data.", + "enum": ["jpg", "pbf", "png", "webp"], + "example": "png" + }, + "maxzoom": { + "type": "number", + "description": "An integer specifying the maximum zoom level. MUST be >= minzoom.", + "example": 27, + "default": 0, + "minimum": 0, + "maximum": 30 + }, + "minzoom": { + "type": "number", + "description": "An integer specifying the minimum zoom level.", + "example": 19, + "default": 0, + "minimum": 0, + "maximum": 30 + }, + "scale": { + "type": "number", + "description": "Map scale", + "minimum": 1, + "default": 250000, + "example": 250000 + } + } + }, + "MapServerSource": { + "description": "Decribes Map server source types.", + "type": "object", + "required": ["type"], + "properties": { + "type": { + "type": "string", + "description": "Source type of map data.", + "enum": ["tilejson", "wms", "wmts"], + "default": "wms", + "example": "wms" + } + } + }, + "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 + }, + "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": [ + { + "$ref": "#/components/schemas/TileLayerSource" + }, + { + "$ref": "#/components/schemas/MapServerSource" + } + ] + }, + "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] + } + }, + "ProviderParam": { + "in": "query", + "name": "provider", + "description": "Plugin id of the resource provider to direct the request to (When multiple providers are registered for a resource type).", + "style": "form", + "explode": false, + "schema": { + "type": "string", + "example": "my-provider" + } + } + }, + "securitySchemes": { + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT" + }, + "cookieAuth": { + "type": "apiKey", + "in": "cookie", + "name": "JAUTHENTICATION" + } + } + }, + "security": [{ "cookieAuth": [] }, { "bearerAuth": [] }], + "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": { + "parameters": [ + { + "$ref": "#/components/parameters/ProviderParam" + } + ], + "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/Route" + } + } + } + }, + "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" + } + }, + { + "$ref": "#/components/parameters/ProviderParam" + } + ], + "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": { + "parameters": [ + { + "$ref": "#/components/parameters/ProviderParam" + } + ], + "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/Waypoint" + } + } + } + }, + "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" + } + }, + { + "$ref": "#/components/parameters/ProviderParam" + } + ], + "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": { + "parameters": [ + { + "$ref": "#/components/parameters/ProviderParam" + } + ], + "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/Region" + } + } + } + }, + "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" + } + }, + { + "$ref": "#/components/parameters/ProviderParam" + } + ], + "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": { + "parameters": [ + { + "$ref": "#/components/parameters/ProviderParam" + } + ], + "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/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" + } + }, + { + "$ref": "#/components/parameters/ProviderParam" + } + ], + "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": { + "parameters": [ + { + "$ref": "#/components/parameters/ProviderParam" + } + ], + "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" + } + ] + } + } + } + } + } + } + } + }, + "/resources/charts/{id}": { + "parameters": [ + { + "name": "id", + "in": "path", + "description": "chart id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/ProviderParam" + } + ], + "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/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/resources/validate.ts b/src/api/resources/validate.ts new file mode 100644 index 000000000..bcf89f6b1 --- /dev/null +++ b/src/api/resources/validate.ts @@ -0,0 +1,78 @@ +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, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + 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, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + 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( + '^[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 index 0b9a19667..555b58dad 100644 --- a/src/api/swagger.ts +++ b/src/api/swagger.ts @@ -1,6 +1,10 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ import { IRouter, Request, Response } from 'express' import swaggerUi from 'swagger-ui-express' import { SERVERROUTESPREFIX } from '../constants' +import { courseApiRecord } from './course/openApi' +import { notificationsApiRecord } from './notifications/openApi' +import { resourcesApiRecord } from './resources/openApi' import { securityApiRecord } from './security/openApi' import { discoveryApiRecord } from './discovery/openApi' import { appsApiRecord } from './apps/openApi' @@ -24,7 +28,10 @@ interface ApiRecords { const apiDocs = [ discoveryApiRecord, appsApiRecord, - securityApiRecord + securityApiRecord, + courseApiRecord, + notificationsApiRecord, + resourcesApiRecord ].reduce((acc, apiRecord: OpenApiRecord) => { acc[apiRecord.name] = apiRecord return acc diff --git a/src/app.ts b/src/app.ts index 4122a1322..73ad42a23 100644 --- a/src/app.ts +++ b/src/app.ts @@ -2,6 +2,7 @@ import { FullSignalK } from '@signalk/signalk-schema' import { Config } from './config/config' import DeltaCache from './deltacache' +import { SKVersion } from './types' export interface ServerApp { started: boolean @@ -20,7 +21,7 @@ export interface SignalKMessageHub { emit: any on: any signalk: FullSignalK - handleMessage: (id: string, data: any) => void + handleMessage: (id: string, Delta: any, skVersion?: SKVersion) => void } export interface WithConfig { diff --git a/src/config/config.ts b/src/config/config.ts index db69fe857..4e966cea5 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -29,6 +29,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 @@ -90,18 +96,21 @@ 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 + config.name = packageJson.name + config.author = packageJson.author + config.contributors = packageJson.contributors + config.version = packageJson.version + config.description = packageJson.description //if dependencies are installed from tarballs like in //master docker build the version will be like //file:signalk-server-admin-ui-1.44.1.tgz if (!process.env.SKIP_ADMINUI_VERSION_CHECK) { - checkPackageVersion('@signalk/server-admin-ui', pkg, app.config.appPath) + checkPackageVersion( + '@signalk/server-admin-ui', + packageJson, + app.config.appPath + ) } } catch (err) { console.error('error parsing package.json', err) @@ -502,5 +511,6 @@ module.exports = { writeDefaultsFile, readDefaultsFile, sendBaseDeltas, - writeBaseDeltasFile + writeBaseDeltasFile, + package: packageJson } diff --git a/src/index.ts b/src/index.ts index ed39a2ebd..d898b8a25 100644 --- a/src/index.ts +++ b/src/index.ts @@ -25,11 +25,12 @@ if (typeof [].includes !== 'function') { import { PropertyValues } from '@signalk/server-api' import { FullSignalK, getSourceId } from '@signalk/signalk-schema' import { Debugger } from 'debug' -import express, { Request, Response } from 'express' +import express, { IRouter, Request, Response } from 'express' 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' @@ -43,11 +44,12 @@ import { getCertificateOptions, getSecurityConfig, saveSecurityConfig, - startSecurity + startSecurity, + WithSecurityStrategy } from './security.js' import { setupCors } from './cors' import SubscriptionManager from './subscriptionmanager' -import { Delta } from './types' +import { Delta, SKVersion } from './types' const debug = createDebug('signalk-server') const { StreamBundle } = require('./streambundle') @@ -57,7 +59,12 @@ interface ServerOptions { } class Server { - app: ServerApp & SelfIdentity & WithConfig & SignalKMessageHub + app: ServerApp & + SelfIdentity & + WithConfig & + SignalKMessageHub & + WithSecurityStrategy & + IRouter constructor(opts: ServerOptions) { const FILEUPLOADSIZELIMIT = process.env.FILEUPLOADSIZELIMIT || '10mb' const bodyParser = require('body-parser') @@ -83,9 +90,17 @@ class Server { app.propertyValues = new PropertyValues() - const deltachain = new DeltaChain(app.signalk.addDelta.bind(app.signalk)) - app.registerDeltaInputHandler = (handler: DeltaInputHandler) => - deltachain.register(handler) + const deltachainV1 = new DeltaChain(app.signalk.addDelta.bind(app.signalk)) + const deltachainV2 = new DeltaChain((delta: Delta) => + app.signalk.emit('delta', delta) + ) + app.registerDeltaInputHandler = (handler: DeltaInputHandler) => { + const unRegisterHandlers = [ + deltachainV1.register(handler), + deltachainV2.register(handler) + ] + return () => unRegisterHandlers.forEach((f) => f()) + } app.providerStatus = {} @@ -93,7 +108,8 @@ class Server { doSetProviderStatus(providerId, statusMessage, 'status', 'plugin') } - app.setPluginError = (providerId: string, errorMessage: string) => { doSetProviderStatus(providerId, errorMessage, 'error', 'plugin') + app.setPluginError = (providerId: string, errorMessage: string) => { + doSetProviderStatus(providerId, errorMessage, 'error', 'plugin') } app.setProviderStatus = (providerId: string, statusMessage: string) => { @@ -196,7 +212,11 @@ class Server { } app.activateSourcePriorities() - app.handleMessage = (providerId: string, data: any) => { + app.handleMessage = ( + providerId: string, + data: any, + skVersion = SKVersion.v1 + ) => { if (data && data.updates) { incDeltaStatistics(app, providerId) @@ -223,7 +243,12 @@ class Server { } }) try { - deltachain.process(toPreferredDelta(data, now, app.selfContext)) + const preferredDelta = toPreferredDelta(data, now, app.selfContext) + if (skVersion == SKVersion.v1) { + deltachainV1.process(preferredDelta) + } else { + deltachainV2.process(preferredDelta) + } } catch (err) { console.error(err) } @@ -349,8 +374,9 @@ class Server { app.intervals.push(startDeltaStatistics(app)) - return new Promise((resolve, reject) => { - createServer(app, (err, server) => { + // eslint-disable-next-line no-async-promise-executor + return new Promise(async (resolve, reject) => { + createServer(app, async (err, server) => { if (err) { reject(err) return @@ -364,6 +390,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 24691d273..e75f6665a 100644 --- a/src/interfaces/plugins.ts +++ b/src/interfaces/plugins.ts @@ -18,7 +18,8 @@ import { PluginServerApp, PropertyValues, - PropertyValuesCallback + PropertyValuesCallback, + ResourceProvider } from '@signalk/server-api' // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore @@ -27,6 +28,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' @@ -34,6 +36,7 @@ import { listAllSerialPorts, Ports } from '../serialports' const debug = createDebug('signalk-server:interfaces:plugins') import { modulesWithKeyword } from '../modules' +import { SKVersion } from '../types' const put = require('../put') const _putPath = put.putPath @@ -90,7 +93,7 @@ export interface ServerAPI extends PluginServerApp { debug: (msg: string) => void registerDeltaInputHandler: (handler: DeltaInputHandler) => void setProviderStatus: (msg: string) => void - handleMessage: (id: string, msg: any) => void + handleMessage: (id: string, msg: any, skVersion?: SKVersion) => void setProviderError: (msg: string) => void savePluginOptions: ( configuration: object, @@ -468,6 +471,9 @@ module.exports = (theApp: any) => { console.error(`${plugin.id}:no configuration data`) safeConfiguration = {} } + onStopHandlers[plugin.id].push(() => + app.resourcesApi.unRegister(plugin.id) + ) plugin.start(safeConfiguration, restart) debug('Started plugin ' + plugin.name) setPluginStartedMessage(plugin) @@ -540,6 +546,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 diff --git a/src/modules.test.js b/src/modules.test.js index 84b43dedf..c4bfcabef 100644 --- a/src/modules.test.js +++ b/src/modules.test.js @@ -12,13 +12,10 @@ 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( - updateInstalledModule - ) const testTempDir = path.join( require('os').tmpdir(), @@ -53,11 +50,14 @@ describe('modulesWithKeyword', () => { ) const moduleList = modulesWithKeyword(app.config, 'signalk-webapp') - chai.expect(_.map(moduleList, 'module')).to.eql(expectedModules) + chai.expect(_.map(moduleList, 'module')).to.have.members(expectedModules) + chai.expect(moduleList[0].location).to.not.eql(tempNodeModules) - chai - .expect(moduleList[indexOfInstalledModule].location) - .to.eql(tempNodeModules) + + const installedModuleInfo = moduleList.find( + (moduleInfo) => moduleInfo.module === updateInstalledModule + ) + chai.expect(installedModuleInfo.location).to.eql(tempNodeModules) }) }) diff --git a/src/security.ts b/src/security.ts index a7375bdbc..fb45f0eaa 100644 --- a/src/security.ts +++ b/src/security.ts @@ -184,6 +184,13 @@ export interface SecurityStrategy { password: string, cb: ICallback ) => void + + shouldAllowPut: ( + req: Request, + context: string, + source: any, + path: string + ) => boolean } export class InvalidTokenError extends Error { diff --git a/src/serverstate/store.ts b/src/serverstate/store.ts new file mode 100644 index 000000000..65d36226b --- /dev/null +++ b/src/serverstate/store.ts @@ -0,0 +1,47 @@ +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) + }) + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async read(): Promise { + const data = await readFile(path.join(this.filePath, this.fileName), 'utf8') + return JSON.parse(data) + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + write(data: any) { + 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 f35836a7c..1ee3cc6d1 100644 --- a/src/subscriptionmanager.ts +++ b/src/subscriptionmanager.ts @@ -15,13 +15,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 e079cfdfc..23a2b49d8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -79,7 +79,8 @@ export type Delta = any export type Path = Brand export type Context = Brand export type Value = object | number | string | null -export interface Position { - latitude: number - longitude: number + +export enum SKVersion { + v1 = 'v1', + v2 = 'v2' } diff --git a/src/types/freeport-promise/index.d.ts b/src/types/freeport-promise/index.d.ts new file mode 100644 index 000000000..300863f46 --- /dev/null +++ b/src/types/freeport-promise/index.d.ts @@ -0,0 +1 @@ +declare module 'freeport-promise' diff --git a/test/course.ts b/test/course.ts new file mode 100644 index 000000000..602f001da --- /dev/null +++ b/test/course.ts @@ -0,0 +1,441 @@ +import { strict as assert } from 'assert' +import chai from 'chai' +import resourcesOpenApi from '../src/api/resources/openApi.json' +import { DATETIME_REGEX, 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 v2courseDelta = JSON.parse(await wsPromiser.nthMessage(4)) + v2courseDelta.context.should.equal(self) + + const expectedPathValues = [ + { + path: 'navigation.course.activeRoute', + value: null + }, + { + path: 'navigation.course.nextPoint', + value: {position: { + latitude: -35.5, + longitude: 138.7 + }, + type: 'Location'} + }, + { + path: 'navigation.course.previousPoint', + value: {position: { + latitude: -35.45, + longitude: 138 + }, type: 'VesselPosition'} + } + ] + expectedPathValues.forEach(({ path, value }) => + deltaHasPathValue(v2courseDelta, path, value) + ) + + await selfGetJson('navigation/course').then(data => { + data.startTime.should.match(DATETIME_REGEX) + delete data.startTime + data.should.deep.equal({ + targetArrivalTime: null, + arrivalCircle: 0, + activeRoute: null, + nextPoint: { + type: 'Location', + position: { latitude: -35.5, longitude: 138.7 } + }, + previousPoint: { + 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 v2courseDelta = JSON.parse(await wsPromiser.nthMessage(4)) + deltaHasPathValue( + v2courseDelta, + 'navigation.course.nextPoint', + {position: validDestinationPosition, type: 'Location'} + ) + + await selfPut('navigation/course/destination', { + href: + '/resources/waypoints/07894aba-f151-4099-aa4f-5e5773734b95' + }).then(response => response.status.should.equal(400)) + await assert.rejects(wsPromiser.nthMessage(5)) + + await selfPut('navigation/course/destination', { + hrefff: 'dummy data' + }).then(response => response.status.should.equal(400)) + await assert.rejects(wsPromiser.nthMessage(5)) + + await selfPut('navigation/course/destination', { + position: { latitude: -35.5 } + }).then(response => response.status.should.equal(400)) + await assert.rejects(wsPromiser.nthMessage(5)) + + 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 = { + feature: { + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [24.9384, 60.1699] + } + } + } + const { id } = await post('/resources/waypoints', destination) + .then(response => { + response.status.should.equal(201) + return response.json() + }) + id.length.should.equal( + '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 v2courseDelta = JSON.parse(await wsPromiser.nthMessage(3)) + v2courseDelta.context.should.equal(self) + + let expectedPathValues = [ + { path: 'navigation.course.activeRoute', value: null }, + { + path: 'navigation.course.nextPoint', + value: { + href: `/resources/waypoints/${id}`, + position: { latitude: 60.1699, longitude: 24.9384 }, + type: 'Waypoint', + + } + }, + { + path: 'navigation.course.previousPoint', + value: { + position: { latitude: -35.45, longitude: 138 }, + type: 'VesselPosition' + } + } + ] + expectedPathValues.forEach(({ path, value }) => + deltaHasPathValue(v2courseDelta, path, value) + ) + + const pathValue = v2courseDelta.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({ + arrivalCircle: 99, + targetArrivalTime: null, + activeRoute: null, + nextPoint: { + href, + type: 'Waypoint', + position: { + longitude: destination.feature.geometry.coordinates[0], + latitude: destination.feature.geometry.coordinates[1] + } + }, + previousPoint: { + type: 'VesselPosition', + position: vesselPosition + } + }) + }) + + await selfDelete('navigation/course').then(response => + response.status.should.equal(200) + ) + const destinationClearedDelta = JSON.parse(await wsPromiser.nthMessage(5)) + expectedPathValues = [ + { + path: 'navigation.course.activeRoute', + value: null + }, + { + path: 'navigation.course.startTime', + value: null + }, + { + path: 'navigation.course.nextPoint', + value: null + }, + { + path: 'navigation.course.previousPoint', + value: null + } + ] + expectedPathValues.forEach(({ path, value }) => + deltaHasPathValue(destinationClearedDelta, path, value) + ) + + await selfGetJson('navigation/course').then(data => { + data.should.deep.equal({ + startTime: null, + targetArrivalTime: null, + activeRoute: null, + arrivalCircle: 99, + nextPoint: null, + previousPoint: 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 = { + feature: { + type: "Feature", + geometry: { + type: "LineString", + coordinates: [[3.3452,65.4567],[3.3352, 65.5567],[3.3261,65.5777]] + } + } + } + + const { id } = await post('/resources/routes', points) + .then(response => { + response.status.should.equal(201) + return response.json() + }) + id.length.should.equal( + '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 v2courseDelta = JSON.parse(await wsPromiser.nthMessage(3)) + v2courseDelta.context.should.equal(self) + + const expectedPathValues = [ + { + path: 'navigation.course.activeRoute', + value: { + href, + pointIndex: 0, + pointTotal: 3, + reverse: false + } + }, + { + path: 'navigation.course.nextPoint', + value: { + position: { + latitude: 65.4567, + longitude: 3.3452 + }, + type: 'RoutePoint' + } + }, + { + path: 'navigation.course.arrivalCircle', + value: 0 + }, + { + path: 'navigation.course.previousPoint', + value: {position: { + latitude: -35.45, + longitude: 138 + }, + type: 'VesselPosition'} + }, + ] + expectedPathValues.forEach(({ path, value }) => + deltaHasPathValue(v2courseDelta, path, value) + ) + v2courseDelta.updates[0].values.find( + (x: any) => x.path === 'navigation.course.startTime' + ).should.not.be.undefined + + await selfGetJson('navigation/course').then(data => { + delete data.startTime + delete data.targetArrivalTime + delete data.activeRoute.name + delete data.activeRoute.waypoints + data.should.deep.equal({ + arrivalCircle: 0, + activeRoute: { + href, + pointIndex: 0, + pointTotal: 3, + reverse: false + }, + nextPoint: { + position: { + longitude: points.feature.geometry.coordinates[0][0], + latitude: points.feature.geometry.coordinates[0][1] + }, + type: 'RoutePoint' + }, + previousPoint: { + 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) + ) + + //setting pointIndex beyond route length sets it to last point's index + await selfPut('navigation/course/activeRoute/nextPoint', { + value: 100 + }).then(response => response.status.should.equal(200)) + await selfGetJson('navigation/course').then(data => + data.activeRoute.pointIndex.should.equal(2) + ) + + 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/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.feature.geometry.coordinates[points.feature.geometry.coordinates.length - 1][1] + ) + ) + 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.feature.geometry.coordinates[1][1]) + data.previousPoint.position.latitude.should.equal( + points.feature.geometry.coordinates[points.feature.geometry.coordinates.length - 1][1] + ) + }) + + 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 v2courseDelta = JSON.parse(await wsPromiser.nthMessage(3)) + + const expectedPathValues = [ + { + path: 'navigation.course.arrivalCircle', + value: 98 + } + ] + expectedPathValues.forEach(({ path, value }) => + deltaHasPathValue(v2courseDelta, path, value) + ) + + await selfGetJson('navigation/course').then(data => { + data.should.deep.equal({ + startTime: null, + targetArrivalTime: null, + arrivalCircle: 98, + activeRoute: null, + nextPoint: null, + previousPoint: null + }) + }) + stop() + }) +}) diff --git a/test/deltacache.js b/test/deltacache.js index 815036d6f..23eca160e 100644 --- a/test/deltacache.js +++ b/test/deltacache.js @@ -3,9 +3,9 @@ chai.Should() chai.use(require('chai-things')) chai.use(require('@signalk/signalk-schema').chaiModule) const _ = require('lodash') -const assert = require('assert') const freeport = require('freeport-promise') const { startServerP, sendDelta } = require('./servertestutilities') +const { COURSE_API_INITIAL_DELTA_COUNT } = require('../lib/api/course/') const testDelta = { context: 'vessels.self', @@ -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 }) }) @@ -209,8 +210,11 @@ describe('deltacache', () => { it('deltas ordered properly', function () { return serverP.then(server => { return deltaP.then(() => { - var deltas = server.app.deltaCache.getCachedDeltas(delta => true, null) - assert(deltas.length == expectedOrder.length) + // eslint-disable-next-line no-unused-vars + var deltas = server.app.deltaCache.getCachedDeltas((delta) => true, null) + .filter(delta => delta.updates[0].$source != 'courseApi') + // console.log(JSON.stringify(deltas, null, 2)) + deltas.length.should.equal(expectedOrder.length) 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 +236,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..9dbfb9fa2 --- /dev/null +++ b/test/resources.ts @@ -0,0 +1,79 @@ +import { Waypoint } from '@signalk/server-api' +import chai from 'chai' +import { v4 as uuidv4 } from 'uuid' +import { startServer } from './ts-servertestutilities' +chai.should() + +export const skUuid = () => `${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/`, { + feature: { + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [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..fea17ab4b 100755 --- a/test/servertestutilities.js +++ b/test/servertestutilities.js @@ -33,12 +33,14 @@ 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 + this.messages = [] } WsPromiser.prototype.nextMsg = function () { @@ -47,7 +49,7 @@ WsPromiser.prototype.nextMsg = function () { callees.push(resolve) setTimeout(_ => { resolve('timeout') - }, 250) + }, this.timeout) }) } @@ -69,8 +71,12 @@ WsPromiser.prototype.nthMessage = function (n) { return this.nthMessagePromiser(n).promise } +WsPromiser.prototype.parsedMessages = function () { + return this.messages.map(m => JSON.parse(m)) +} WsPromiser.prototype.onMessage = function (message) { + this.messages.push(message) const theCallees = this.callees this.callees = [] theCallees.forEach(callee => callee(message)) @@ -100,9 +106,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 +135,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 6b774a620..c8a529e72 100644 --- a/test/subscriptions.js +++ b/test/subscriptions.js @@ -205,16 +205,21 @@ 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) - assert(JSON.parse(echoedDelta).updates[0].source.pgn === 128275) + //wait 250 ms so that we've received everything over ws + await new Promise((resolve) => setTimeout(resolve, 250)) - try { - await wsPromiser.nthMessage(4) - throw new Error('no more messages should arrive') - } catch (e) { - assert.strictEqual(e, 'timeout') - } + //retrieve all deltas thus far + const messages = wsPromiser.parsedMessages().slice(1) + + //all deltas must have self context + messages.forEach((delta) => delta.context.should.equal(self)) + + //check for the delta we sent + messages + .findIndex( + (delta) => delta.updates[0].source && delta.updates[0].source.pgn + ) + .should.be.at.least(0) } it('?subscribe=self subscription serves self data', async function () { @@ -236,14 +241,19 @@ describe('Subscriptions', _ => { await sendDelta(getDelta({ context: self }), deltaUrl) await sendDelta(getDelta({ context: 'vessels.othervessel' }), deltaUrl) - //skip 2nd message that is delta from defaults - const echoedSelfDelta = await wsPromiser.nthMessage(3) - assert(JSON.parse(echoedSelfDelta).updates[0].source.pgn === 128275) - - const echoedOtherDelta = await wsPromiser.nthMessage(4) + //wait so that we've received everything over ws + await new Promise((resolve) => setTimeout(resolve, 100)) + const deltas = wsPromiser.parsedMessages().slice(1) + const deltasWeSent = deltas.filter( + (d) => d.updates[0].source && d.updates[0].source.pgn === 128275 + ) + assert( + deltasWeSent.filter((d) => d.context === self).length === 1, + 'Received self delta' + ) assert( - JSON.parse(echoedOtherDelta).context === 'vessels.othervessel', - 'Sends other vessel data' + deltasWeSent.filter((d) => d.context === 'vessels.othervessel').length === 1, + 'Received other vessel delta' ) }) @@ -393,7 +403,7 @@ describe('Subscriptions', _ => { ) assert(delta.updates.length === 1, 'Receives just one update') assert(delta.updates[0].values.length === 1, 'Receives just one value') - assert(delta.context === 'vessels.' + self) + assert(delta.context === `vessels.${self}`) assert(delta.updates[0].timestamp, '2014-05-03T09:14:11.001Z') return wsPromiser.nthMessage(6) //othervessel, 1st delta diff --git a/test/ts-servertestutilities.ts b/test/ts-servertestutilities.ts new file mode 100644 index 000000000..1a71d67d6 --- /dev/null +++ b/test/ts-servertestutilities.ts @@ -0,0 +1,97 @@ +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' + +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'] + .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 725a31964..75f6a09af 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,12 +6,11 @@ "esModuleInterop": true, "strict": true, "allowJs": true, + "typeRoots": ["./src/types", "./node_modules/@types"], "resolveJsonModule": true, "rootDir": "./src" }, - "include": [ - "./src/**/*" - ], + "include": ["./src/**/*"], "exclude": ["node_modules"], "typedocOptions": { "mode": "modules", @@ -28,5 +27,13 @@ "suppressExcessPropertyErrors": true, "suppressImplicitAnyIndexErrors": true, "module": "commonjs" - } + }, + "references": [ + { + "path": "./packages/server-api" + }, + { + "path": "./packages/resources-provider-plugin" + } + ] }