diff --git a/RESOURCE_PROVIDER_PLUGINS.md b/RESOURCE_PROVIDER_PLUGINS.md new file mode 100644 index 000000000..5cd47f6fa --- /dev/null +++ b/RESOURCE_PROVIDER_PLUGINS.md @@ -0,0 +1,338 @@ +# 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._ + +--- + +## Overview + +The SignalK specification defines the path `/signalk/v1/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/v1/api/resources/routes`)_. + +It should also be noted that the `/signalk/v1/api/resources` path can also host other types of resource data which can be grouped within a __Custom__ path name _(e.g. `/signalk/v1/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__. + +This de-coupling of resource request handling and storage / retrieval provides great flexibility to ensure that an appropriate resource storage solution can be configured for your SignalK implementation. + +SignalK server handles requests for both __Common__ and __Custom__ resource types in a similar manner, the only difference being that it does not perform any validation on __Custom__ resource data, so a plugin can act a s a provider for both types. + +--- +## Server Operation: + +The Signal K server handles all requests to `/signalk/v1/api/resources` and all 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 ResourceProvider methods are defined +- For __Common__ resource types, checks the validity of the: + - Resource id + - Submitted resource data. + +Upon successful completion of these operations the request will then be passed to the registered resource provider plugin. + +--- +## Resource Provider plugin: + +For a plugin to be considered a Resource Provider it needs to implement the `ResourceProvider` interface. + +By implementing this interface the plugin is able to register with the SignalK server the: +- Resource types provided for by the plugin +- Methods to used to action requests. + +It is these methods that perform the retrival, saving and deletion of resources from storage. + + +### Resource Provider Interface + +--- +The `ResourceProvider` interface defines the contract between the the Resource Provider plugin and the SignalK server and has the following definition _(which it and other related types can be imported from `@signalk/server-api`)_: + +```typescript +interface ResourceProvider: { + types: string[], + methods: { + listResources: (type:string, query: {[key:string]:any})=> Promise + getResource: (type:string, id:string)=> Promise + setResource: (type:string, id:string, value:{[key:string]:any})=> Promise + deleteResource: (type:string, id:string)=> Promise + } +} +``` +where: + +- `types`: An array containing a list of resource types provided for by the plugin. These can be a mixture of both __Common__ and __Custom__ resource types. +- `methods`: An object containing the methods resource requests are passed to by the SignalK server. The plugin __MUST__ implement each method, even if that operation is not supported by the plugin! + +#### __Method Details:__ + +--- +__`listResources(type, query)`__: This method is called when a request is made for resource entries of a specific resource type that match a specifiec criteria. + +_Note: It is the responsibility of the resource provider plugin to filter the resources returned as per the supplied query parameters._ + +`type:` String containing the type of resource to retrieve. + +`query:` Object contining `key | value` pairs repesenting the parameters by which to filter the returned entries. _e.g. {distance,'50000}_ + +`returns:` +- Resolved Promise containing a list of resource entries on completion. +- Rejected Promise containing an Error if incomplete or not implemented. + + +_Example resource request:_ +``` +GET /signalk/v1/api/resources/waypoints?bbox=5.4,25.7,6.9,31.2&distance=30000 +``` +_ResourceProvider method invocation:_ + +```javascript +listResources( + 'waypoints', + { + bbox: '5.4,25.7,6.9,31.2', + distance: 30000 + } +); +``` + +--- +__`getResource(type, id)`__: This method is called when a request is made for a specific resource entry of the supplied resource type and id. + +`type:` String containing the type of resource to retrieve. + +`id:` String containing the target resource entry id. _e.g. 'urn:mrn:signalk:uuid:07894aba-f151-4099-aa4f-5e5773734b99'_ + +`returns:` +- Resolved Promise containing the resource entry on completion. +- Rejected Promise containing an Error if incomplete or not implemented. + +_Example resource request:_ +``` +GET /signalk/v1/api/resources/routes/urn:mrn:signalk:uuid:07894aba-f151-4099-aa4f-5e5773734b99 +``` +_ResourceProvider method invocation:_ + +```javascript +getResource( + 'routes', + 'urn:mrn:signalk:uuid:07894aba-f151-4099-aa4f-5e5773734b99' +); +``` + +--- +__`setResource(type, id, value)`__: This method is called when a request is made to save / update a resource entry of the specified resource type, with the supplied id and data. + +`type:` String containing the type of resource to store. + +`id:` String containing the target resource entry id. _e.g. 'urn:mrn:signalk:uuid:07894aba-f151-4099-aa4f-5e5773734b99'_ + +`value:` Resource data to be stored. + +`returns:` +- Resolved Promise containing a list of resource entries on completion. +- Rejected Promise containing an Error if incomplete or not implemented. + +_Example PUT resource request:_ +``` +PUT /signalk/v1/api/resources/routes/urn:mrn:signalk:uuid:07894aba-f151-4099-aa4f-5e5773734b99 {resource_data} +``` +_ResourceProvider method invocation:_ + +```javascript +setResource( + 'routes', + 'urn:mrn:signalk:uuid:07894aba-f151-4099-aa4f-5e5773734b99', + {} +); +``` + +_Example POST resource request:_ +``` +POST /signalk/v1/api/resources/routes {resource_data} +``` +_ResourceProvider method invocation:_ + +```javascript +setResource( + 'routes', + '', + {} +); +``` + +--- +__`deleteResource(type, id)`__: This method is called when a request is made to remove the specific resource entry of the supplied resource type and id. + +`type:` String containing the type of resource to delete. + +`id:` String containing the target resource entry id. _e.g. 'urn:mrn:signalk:uuid:07894aba-f151-4099-aa4f-5e5773734b99'_ + +`returns:` +- Resolved Promise on completion. +- Rejected Promise containing an Error if incomplete or not implemented. + +_Example resource request:_ +``` +DELETE /signalk/v1/api/resources/routes/urn:mrn:signalk:uuid:07894aba-f151-4099-aa4f-5e5773734b99 +``` +_ResourceProvider method invocation:_ + +```javascript +deleteResource( + 'routes', + 'urn:mrn:signalk:uuid:07894aba-f151-4099-aa4f-5e5773734b99' +); +``` + +### Registering a Resource Provider: +--- + +To register the resource provider plugin with the SignalK server, the server's `resourcesApi.register()` function should be called during plugin startup. + +The server `resourcesApi.register()` function has the following signature: + +```typescript +app.resourcesApi.register(pluginId: string, resourceProvider: ResourceProvider) +``` +where: +- `pluginId`: is the plugin's id +- `resourceProvider`: is a reference to the plugins ResourceProvider interface. + +_Note: A resource type can only have one registered plugin, so if more than one plugin attempts to register as a provider for the same resource type, the first plugin to call the `register()` function will be registered by the server for the resource types defined in the ResourceProvider interface!_ + +_Example:_ +```javascript +module.exports = function (app) { + + let plugin= { + id: 'mypluginid', + name: 'My Resource Providerplugin', + resourceProvider: { + types: ['routes','waypoints'], + methods: { + listResources: (type, params)=> { ... }, + getResource: (type:string, id:string)=> { ... } , + setResource: (type:string, id:string, value:any)=> { ... }, + deleteResource: (type:string, id:string)=> { ... } + } + } + } + + plugin.start = function(options) { + ... + app.resourcesApi.register(plugin.id, plugin.resourceProvider); + } +} +``` + +### Un-registering the Resource Provider: +--- + +When a resource provider plugin is disabled, it should un-register itself to ensure resource requests are no longer directed to it by calling the SignalK server. This should be done by calling the server's `resourcesApi.unRegister()` function during shutdown. + +The server `resourcesApi.unRegister()` function has the following signature: + +```typescript +app.resourcesApi.unRegister(pluginId: string) +``` +where: +- `pluginId`: is the plugin's id + + +_Example:_ +```javascript +module.exports = function (app) { + + let plugin= { + id: 'mypluginid', + name: 'My Resource Providerplugin', + resourceProvider: { + types: [ ... ], + methods: { ... } + } + } + + plugin.stop = function(options) { + app.resourcesApi.unRegister(plugin.id); + ... + } +} +``` + +--- + +### __Example:__ + +Resource Provider plugin providing for the retrieval of routes & waypoints. + +```javascript +// SignalK server plugin +module.exports = function (app) { + + let plugin= { + id: 'mypluginid', + name: 'My Resource Providerplugin', + // ResourceProvider interface + resourceProvider: { + types: ['routes','waypoints'], + methods: { + listResources: (type, params)=> { + return new Promise( (resolve, reject) => { + // fetch resource entries from storage + .... + if(ok) { // success + resolve({ + 'id1': { ... }, + 'id2': { ... }, + }); + } else { // error + reject(new Error('Error encountered!') + } + } + }, + getResource: (type, id)=> { + // fetch resource entries from storage + .... + if(ok) { // success + return Promise.resolve({ + ... + }); + } else { // error + reject(new Error('Error encountered!') + } + }, + setResource: (type, id, value)=> { + // not implemented + return Promise.reject(new Error('NOT IMPLEMENTED!')); + }, + deleteResource: (type, id)=> { + // not implemented + return Promise.reject(new Error('NOT IMPLEMENTED!')); + } + } + }, + + start: (options)=> { + ... + app.resourceApi.register(this.id, this.resourceProvider); + }, + + stop: ()=> { + app.resourceApi.unRegister(this.id); + ... + } + } +} +``` diff --git a/SERVERPLUGINS.md b/SERVERPLUGINS.md index 72e8f7634..bad366eb2 100644 --- a/SERVERPLUGINS.md +++ b/SERVERPLUGINS.md @@ -22,7 +22,9 @@ The plugin module must export a single `function(app)` that must return an objec ## Getting Started with Plugin Development -To get started with SignalK plugin development, you can follow the following guide. +To get started with SignalK plugin development, you can follow this guide. + +_Note: For plugins acting as a provider for one or more of the SignalK resource types listed in the specification (`routes`, `waypoints`, `notes`, `regions` or `charts`) please refer to __[RESOURCE_PROVIDER_PLUGINS.md](./RESOURCE_PROVIDER_PLUGINS.md)__ for additional details._ ### Project setup @@ -698,6 +700,83 @@ app.registerDeltaInputHandler((delta, next) => { }) ``` +### `app.resourcesApi.getResource(resource_type, resource_id)` + +Retrieve resource data for the supplied SignalK resource type and resource id. + +_Valid resource types are `routes`, `waypoints`, `notes`, `regions` & `charts`._ + + +This method invokes the `registered Resource Provider` for the supplied `resource_type` and returns a `resovled` __Promise__ containing the resource data if successful or +a `rejected` __Promise__ containing an __Error__ object if unsuccessful. + +_Example:_ +```javascript +let resource= app.resourcesApi.getResource('routes', 'urn:mrn:signalk:uuid:ac3a3b2d-07e8-4f25-92bc-98e7c92f7f1a'); + +resource.then ( (data)=> { + // route data + console.log(data); + ... +}).catch (error) { + // handle error + console.log(error.message); + ... +} +``` + + +### `app.resourcesApi.register(pluginId, resourceProvider)` + +If a plugin wants to act as a resource provider, it will need to register its provider methods during startup using this function. + +See [`RESOURCE_PROVIDER_PLUGINS.md`](./RESOURCE_PROVIDER_PLUGINS.md) for details. + + +```javascript +module.exports = function (app) { + let plugin= { + id: 'mypluginid', + name: 'My Resource Providerplugin', + resourceProvider: { + types: ['routes','waypoints'], + methods: { ... } + } + start: function(options) { + // do plugin start up + app.resourcesApi.register(this.id, this.resourceProvider); + } + ... + } +} +``` + +### `app.resourcesApi.unRegister(pluginId)` + +When a resource provider plugin is disabled it will need to un-register its provider methods for all of the resource types it manages. This should be done in the plugin's `stop()` function. + +See [`RESOURCE_PROVIDER_PLUGINS.md`](./RESOURCE_PROVIDER_PLUGINS.md) for details. + + +```javascript +module.exports = function (app) { + let plugin= { + id: 'mypluginid', + name: 'My Resource Providerplugin', + resourceProvider: { + types: ['routes','waypoints'], + methods: { ... } + } + ... + stop: function(options) { + app.resourcesApi.unRegister(this.id); + // do plugin shutdown + } + } +} +``` + + ### `app.setPluginStatus(msg)` Set the current status of the plugin. The `msg` should be a short message describing the current status of the plugin and will be displayed in the plugin configuration UI and the Dashboard. diff --git a/WORKING_WITH_RESOURCES_API.md b/WORKING_WITH_RESOURCES_API.md new file mode 100644 index 000000000..f972ea2c4 --- /dev/null +++ b/WORKING_WITH_RESOURCES_API.md @@ -0,0 +1,320 @@ +# Working with the Resources API + + +## Overview + +The SignalK specification defines a number of resources (routes, waypoints, notes, regions & charts) each with its path under the root `resources` path _(e.g. `/signalk/v1/api/resources/routes`)_. + +The SignalK server handles requests to these resource paths to enable the retrieval, creation, updating and deletion of resources. + +--- +## Operation: + +For resources to be stored and retrieved, the Signal K server requires that a [Resource Provider plugin](RESOURCE_PROVIDER_PLUGINS.md) be installed and registered to manage each of the resource types your implementation requires. _You can find plugins in the `App Store` section of the server admin UI._ + +Client applications can then use HTTP requests to resource paths to store and retrieve 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. + +_Example:_ +```typescript +HTTP GET 'http://hostname:3000/signalk/v1/api/resources/routes' +``` +to return a list of available routes OR +```typescript +HTTP GET 'http://hostname:3000/signalk/v1/api/resources/routes/urn:mrn:signalk:uuid:94052456-65fa-48ce-a85d-41b78a9d2111' +``` +to retrieve a specific resource entry. + +When retrieving a list of entries these can be filtered based on certain criteria such as: + +- being within a bounded area +- distance from vessel +- total entries returned. + +This is done by supplying query string key | value pairs in the request. + +_Example 1: Retrieve waypoints within 50km of the vessel_ +```typescript +HTTP GET 'http://hostname:3000/signalk/v1/api/resources/waypoints?distance=50000' +``` +_Note: the distance supplied is in meters_. + +_Example 2: Retrieve the first 20 waypoints within 90km of the vessel_ +```typescript +HTTP GET 'http://hostname:3000/signalk/v1/api/resources/waypoints?distance=90000&limit=20' +``` +_Note: the distance supplied is in meters_. + +_Example 3: Retrieve waypoints within a bounded area._ +```typescript +HTTP GET 'http://hostname:3000/signalk/v1/api/resources/waypoints?bbox=-135.5,38,-134,38.5' +``` +_Note: the bounded area is supplied as bottom left & top right corner coordinates in the form swLongitude,swLatitude,neLongitude,neLatitude_. + + +### Deleting Resources +--- + +Resource entries are deleted by submitting an HTTP `DELETE` request to a path containing the id of the resource to delete. + +_Example:_ +```typescript +HTTP DELETE 'http://hostname:3000/signalk/v1/api/resources/routes/urn:mrn:signalk:uuid:94052456-65fa-48ce-a85d-41b78a9d2111' +``` + +In this example the route with the supplied id is deleted from storage. + +### Creating / updating Resources +--- + +Resource entries are created by submitting an HTTP `POST` request to the relevant API path that does NOT include a resource `id`. In this instance the resource is created with an `id` that is generated by the server. + +___Note: Each `POST` will generate a new `id` so if the resource data remains the same duplicate resources will be created.___ + +_Example: Create a new route._ +```typescript +HTTP POST 'http://hostname:3000/signalk/v1/api/resources/set/route' {..} +``` + +Resource entries are updated by submitting an HTTP `PUT` request to path that includes a resource `id`. + +_Example: Update a route entry._ +```typescript +HTTP PUT 'http://hostname:3000/signalk/v1/api/resources/set/route/urn:mrn:signalk:uuid:94052456-65fa-48ce-a85d-41b78a9d2111' {..} +``` + +The body of both `POST` and `PUT` requests should contain valid resource data for the specific resource type in the API path. + +Each resource type has a specific set of attributes that are required to be supplied before the resource entry can be created or updated. + +___Note: When submitting data to create or update a resource entry, the submitted resource data is validated against the Signal K schema for that resource type. If the submitted data is deemed to be invalid then the operation is aborted.___ + +___Additionally when supplying an id to assign to or identify the resource on which to perform the operation, the id must be valid for the type of resource as defined in the Signal K schema.___ + +--- +#### __Routes:__ + +To create / update a route entry the body of the request must contain data in the following format: +```javascript +{ + name: 'route name', + description: 'description of the route', + attributes: { + ... + }, + points: [ + {latitude: -38.567,longitude: 135.9467}, + {latitude: -38.967,longitude: 135.2467}, + {latitude: -39.367,longitude: 134.7467}, + {latitude: -39.567,longitude: 134.4467} + ] +} +``` +where: +- name: is text detailing the name of the route +- description (optional): is text describing the route +- attributes (optional): object containing key | value pairs of attributes associated with the route +- points: is an array of route points (latitude and longitude) + + +_Example: Create new route entry (with server generated id)_ +```typescript +HTTP POST 'http://hostname:3000/signalk/v1/api/resources/set/route' { + name: 'route name', + description: 'description of the route', + attributes: { + distance: 6580 + }, + points: [ + {latitude: -38.567,longitude: 135.9467}, + {latitude: -38.967,longitude: 135.2467}, + {latitude: -39.367,longitude: 134.7467}, + {latitude: -39.567,longitude: 134.4467} + ] +} +``` + +_Example: Create new route entry (with supplied id)_ +```typescript +HTTP PUT 'http://hostname:3000/signalk/v1/api/resources/set/route/urn:mrn:signalk:uuid:94052456-65fa-48ce-a85d-41b78a9d2111' { + name: 'route name', + description: 'description of the route', + attributes: { + distance: 6580 + }, + points: [ + {latitude: -38.567,longitude: 135.9467}, + {latitude: -38.967,longitude: 135.2467}, + {latitude: -39.367,longitude: 134.7467}, + {latitude: -39.567,longitude: 134.4467} + ] +} +``` + +--- +#### __Waypoints:__ + +To create / update a waypoint entry the body of the request must contain data in the following format: +```javascript +{ + name: 'waypoint name', + description: 'description of the waypoint', + attributes: { + ... + }, + position: { + latitude: -38.567, + longitude: 135.9467 + } +} +``` +where: +- name: is text detailing the name of the waypoint +- description (optional): is text describing the waypoint +- attributes (optional): object containing key | value pairs of attributes associated with the waypoint +- position: the latitude and longitude of the waypoint + + +_Example: Create new waypoint entry (with server generated id)_ +```typescript +HTTP POST 'http://hostname:3000/signalk/v1/api/resources/set/waypoint' { + name: 'waypoint #1', + position: { + latitude: -38.567, + longitude: 135.9467 + } +} +``` + +_Example: Create new waypoint entry (with supplied id)_ +```typescript +HTTP PUT 'http://hostname:3000/signalk/v1/api/resources/set/waypoint/urn:mrn:signalk:uuid:94052456-65fa-48ce-a85d-41b78a9d2111' { + name: 'waypoint #1', + position: { + latitude: -38.567, + longitude: 135.9467 + } +} +``` + +--- +#### __Regions:__ + +To create / update a region entry the body of the request must contain data in the following format: +```javascript +{ + name: 'region name', + description: 'description of the region', + attributes: { + ... + }, + points: [ + {latitude: -38.567,longitude: 135.9467}, + {latitude: -38.967,longitude: 135.2467}, + {latitude: -39.367,longitude: 134.7467}, + {latitude: -39.567,longitude: 134.4467}, + {latitude: -38.567,longitude: 135.9467} + ] +} +``` +where: +- name: is text detailing the name of the region +- description (optional): is text describing the region +- attributes (optional): object containing key | value pairs of attributes associated with the region +- points: is an array of points (latitude and longitude) defining an area. + + +_Example: Create new region entry (with server generated id)_ +```typescript +HTTP POST 'http://hostname:3000/signalk/v1/api/resources/set/region' { + name: 'region name', + description: 'description of the region', + points: [ + {latitude: -38.567,longitude: 135.9467}, + {latitude: -38.967,longitude: 135.2467}, + {latitude: -39.367,longitude: 134.7467}, + {latitude: -39.567,longitude: 134.4467}, + {latitude: -38.567,longitude: 135.9467} + ] +} +``` + +_Example: Create new region entry (with supplied id)_ +```typescript +HTTP PUT 'http://hostname:3000/signalk/v1/api/resources/set/region/urn:mrn:signalk:uuid:94052456-65fa-48ce-a85d-41b78a9d2111' { + name: 'region name', + description: 'description of the region', + points: [ + {latitude: -38.567,longitude: 135.9467}, + {latitude: -38.967,longitude: 135.2467}, + {latitude: -39.367,longitude: 134.7467}, + {latitude: -39.567,longitude: 134.4467}, + {latitude: -38.567,longitude: 135.9467} + ] +} +``` + +--- +#### __Notes:__ + +To create / update a note entry the body of the request must contain data in the following format: +```javascript +{ + title: 'note title text', + description: 'description of the note', + attributes: { + attribute1: 'attribute1 value', + attribute2: 258, + ... + }, + url: 'link to note content', + mimeType: 'text/plain, text/html, etc.', + position: { + latitude: -38.567, + longitude: 135.9467 + }, + href: 'reference to resource entry' +} +``` +where: +- name: is text detailing the name of the note +- description (optional): is text describing the note +- attributes (optional): object containing key | value pairs of attributes associated with the note +- url (optional): link to the note contents +- mimeType (optional): the mime type of the note contents + +and either: +- position: the latitude and longitude associated with the note + +OR +- href: text containing a reference to a resource associated with the note _e.g. '/resources/regions/urn:mrn:signalk:uuid:35052456-65fa-48ce-a85d-41b78a9d2a61'_ + + +_Example: Create new note entry (with server generated id)_ +```typescript +HTTP POST 'http://hostname:3000/signalk/v1/api/resources/set/note' { + title: 'note title', + description: 'text containing brief description', + url: 'http:notehost.com/notes/mynote.html', + mimeType: 'text/plain', + position: { + latitude: -38.567, + longitude: 135.9467 + } +} +``` + +_Example: Create new note entry (with supplied id)_ +```typescript +HTTP PUT 'http://hostname:3000/signalk/v1/api/resources/set/note/urn:mrn:signalk:uuid:94052456-65fa-48ce-a85d-41b78a9d2111' { + title: 'note title', + description: 'text containing brief description', + href: '/resources/regions/urn:mrn:signalk:uuid:35052456-65fa-48ce-a85d-41b78a9d2a61' +} +``` + diff --git a/package.json b/package.json index 1eb9f574a..77e772783 100644 --- a/package.json +++ b/package.json @@ -96,6 +96,7 @@ "figlet": "^1.2.0", "file-timestamp-stream": "^2.1.2", "flatmap": "0.0.3", + "geojson-validation": "^1.0.2", "geolib": "3.2.2", "get-installed-path": "^4.0.8", "inquirer": "^7.0.0", @@ -109,6 +110,7 @@ "morgan": "^1.5.0", "ms": "^2.1.2", "ncp": "^2.0.0", + "ngeohash": "^0.6.3", "node-fetch": "^2.6.0", "pem": "^1.14.3", "primus": "^7.0.0", @@ -135,6 +137,7 @@ "@types/express": "^4.17.1", "@types/lodash": "^4.14.139", "@types/mocha": "^8.2.0", + "@types/ngeohash": "^0.6.4", "@types/node-fetch": "^2.5.3", "@types/semver": "^7.1.0", "@types/serialport": "^8.0.1", diff --git a/packages/server-api/src/index.ts b/packages/server-api/src/index.ts index 7b9489495..3f3b45925 100644 --- a/packages/server-api/src/index.ts +++ b/packages/server-api/src/index.ts @@ -3,6 +3,27 @@ import { PropertyValues, PropertyValuesCallback } from './propertyvalues' export { PropertyValue, PropertyValues, PropertyValuesCallback } from './propertyvalues' + +export type SignalKResourceType= 'routes' | 'waypoints' |'notes' |'regions' |'charts' +export type ResourceTypes= SignalKResourceType[] | string[] + +export interface ResourceProviderMethods { + pluginId?: string + listResources: (type: string, query: { [key: string]: any }) => Promise + getResource: (type: string, id: string) => Promise + setResource: ( + type: string, + id: string, + value: { [key: string]: any } + ) => Promise + deleteResource: (type: string, id: string) => Promise +} + +export interface ResourceProvider { + types: ResourceTypes + methods: ResourceProviderMethods +} + type Unsubscribe = () => {} export interface PropertyValuesEmitter { emitPropertyValue: (name: string, value: any) => void @@ -54,4 +75,5 @@ export interface Plugin { registerWithRouter?: (router: IRouter) => void signalKApiRoutes?: (router: IRouter) => IRouter enabledByDefault?: boolean + resourceProvider: ResourceProvider } diff --git a/src/@types/geojson-validation.d.ts b/src/@types/geojson-validation.d.ts new file mode 100644 index 000000000..a2e92c5c9 --- /dev/null +++ b/src/@types/geojson-validation.d.ts @@ -0,0 +1 @@ +declare module 'geojson-validation' diff --git a/src/api/resources/index.ts b/src/api/resources/index.ts new file mode 100644 index 000000000..91243ca81 --- /dev/null +++ b/src/api/resources/index.ts @@ -0,0 +1,511 @@ +import { + ResourceProvider, + ResourceProviderMethods, + SignalKResourceType +} from '@signalk/server-api' +import Debug from 'debug' +import { Application, NextFunction, Request, Response } from 'express' +import { v4 as uuidv4 } from 'uuid' +import { WithSecurityStrategy, WithSignalK } from '../../app' + +import { buildResource } from './resources' +import { validate } from './validate' + +const debug = Debug('signalk:resources') + +const SIGNALK_API_PATH = `/signalk/v1/api` +const UUID_PREFIX = 'urn:mrn:signalk:uuid:' + +interface ResourceApplication + extends Application, + WithSignalK, + WithSecurityStrategy {} + +export class Resources { + private resProvider: { [key: string]: ResourceProviderMethods | null } = {} + private server: ResourceApplication + + private signalkResTypes: SignalKResourceType[] = [ + 'routes', + 'waypoints', + 'notes', + 'regions', + 'charts' + ] + + constructor(app: ResourceApplication) { + this.server = app + this.start(app) + } + + register(pluginId: string, provider: ResourceProvider) { + debug(`** Registering provider(s)....${provider?.types}`) + if (!provider) { + return + } + if (provider.types && !Array.isArray(provider.types)) { + return + } + provider.types.forEach((i: string) => { + if (!this.resProvider[i]) { + 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' + ) { + console.error(`Error: Could not register Resource Provider for ${i.toUpperCase()} due to missing provider methods!`) + return + } else { + provider.methods.pluginId = pluginId + this.resProvider[i] = provider.methods + } + } + }) + debug(this.resProvider) + } + + unRegister(pluginId: string) { + if (!pluginId) { + return + } + debug(`** Un-registering ${pluginId} resource provider(s)....`) + for (const resourceType in this.resProvider) { + if (this.resProvider[resourceType]?.pluginId === pluginId) { + debug(`** Un-registering ${resourceType}....`) + delete this.resProvider[resourceType] + } + } + debug(JSON.stringify(this.resProvider)) + } + + getResource(resType: SignalKResourceType, resId: string) { + debug(`** getResource(${resType}, ${resId})`) + if (!this.checkForProvider(resType)) { + return Promise.reject(new Error(`No provider for ${resType}`)) + } + return this.resProvider[resType]?.getResource(resType, resId) + } + + private start(app: any) { + debug(`** Initialise ${SIGNALK_API_PATH}/resources path handler **`) + this.server = app + this.initResourceRoutes() + } + + private updateAllowed(): boolean { + return this.server.securityStrategy.shouldAllowPut( + this.server, + 'vessels.self', + null, + 'resources' + ) + } + + private initResourceRoutes() { + // list all serviced paths under resources + this.server.get( + `${SIGNALK_API_PATH}/resources`, + (req: Request, res: Response) => { + res.json(this.getResourcePaths()) + } + ) + + // facilitate retrieval of a specific resource + this.server.get( + `${SIGNALK_API_PATH}/resources/:resourceType/:resourceId`, + async (req: Request, res: Response, next: NextFunction) => { + debug(`** GET ${SIGNALK_API_PATH}/resources/:resourceType/:resourceId`) + if ( + !this.checkForProvider(req.params.resourceType as SignalKResourceType) + ) { + debug('** No provider found... calling next()...') + next() + return + } + try { + const retVal = await this.resProvider[ + req.params.resourceType + ]?.getResource(req.params.resourceType, req.params.resourceId) + res.json(retVal) + } catch (err) { + res.status(404).send(`Resource not found! (${req.params.resourceId})`) + } + } + ) + + // facilitate retrieval of a collection of resource entries + this.server.get( + `${SIGNALK_API_PATH}/resources/:resourceType`, + async (req: Request, res: Response, next: NextFunction) => { + debug(`** GET ${SIGNALK_API_PATH}/resources/:resourceType`) + if ( + !this.checkForProvider(req.params.resourceType as SignalKResourceType) + ) { + debug('** No provider found... calling next()...') + next() + return + } + try { + const retVal = await this.resProvider[ + req.params.resourceType + ]?.listResources(req.params.resourceType, req.query) + res.json(retVal) + } catch (err) { + res.status(404).send(`Error retrieving resources!`) + } + } + ) + + // facilitate creation of new resource entry of supplied type + this.server.post( + `${SIGNALK_API_PATH}/resources/:resourceType`, + async (req: Request, res: Response, next: NextFunction) => { + debug(`** POST ${SIGNALK_API_PATH}/resources/:resourceType`) + + if ( + !this.checkForProvider(req.params.resourceType as SignalKResourceType) + ) { + debug('** No provider found... calling next()...') + next() + return + } + + if (!this.updateAllowed()) { + res.status(403) + return + } + if ( + this.signalkResTypes.includes( + req.params.resourceType as SignalKResourceType + ) + ) { + if (!validate.resource(req.params.resourceType, req.body)) { + res.status(406).send(`Invalid resource data supplied!`) + return + } + } + + let id: string + if (req.params.resourceType === 'charts') { + id = req.body.identifier + } else { + id = UUID_PREFIX + uuidv4() + } + + try { + const retVal = await this.resProvider[ + req.params.resourceType + ]?.setResource(req.params.resourceType, id, req.body) + + this.server.handleMessage( + this.resProvider[req.params.resourceType]?.pluginId as string, + this.buildDeltaMsg( + req.params.resourceType as SignalKResourceType, + id, + req.body + ) + ) + res + .status(200) + .send(`New ${req.params.resourceType} resource (${id}) saved.`) + } catch (err) { + res + .status(404) + .send(`Error saving ${req.params.resourceType} resource (${id})!`) + } + } + ) + + // facilitate creation / update of resource entry at supplied id + this.server.put( + `${SIGNALK_API_PATH}/resources/:resourceType/:resourceId`, + async (req: Request, res: Response, next: NextFunction) => { + debug(`** PUT ${SIGNALK_API_PATH}/resources/:resourceType/:resourceId`) + if ( + !this.checkForProvider(req.params.resourceType as SignalKResourceType) + ) { + debug('** No provider found... calling next()...') + next() + return + } + + if (!this.updateAllowed()) { + res.status(403) + return + } + if ( + this.signalkResTypes.includes( + req.params.resourceType as SignalKResourceType + ) + ) { + 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(406) + .send(`Invalid resource id provided (${req.params.resourceId})`) + return + } + + if (!validate.resource(req.params.resourceType, req.body)) { + res.status(406).send(`Invalid resource data supplied!`) + return + } + } + + try { + const retVal = await this.resProvider[ + req.params.resourceType + ]?.setResource( + req.params.resourceType, + req.params.resourceId, + req.body + ) + + this.server.handleMessage( + this.resProvider[req.params.resourceType]?.pluginId as string, + this.buildDeltaMsg( + req.params.resourceType as SignalKResourceType, + req.params.resourceId, + req.body + ) + ) + res + .status(200) + .send( + `${req.params.resourceType} resource (${req.params.resourceId}) saved.` + ) + } catch (err) { + res + .status(404) + .send( + `Error saving ${req.params.resourceType} resource (${req.params.resourceId})!` + ) + } + } + ) + + // facilitate deletion of specific of resource entry at supplied id + this.server.delete( + `${SIGNALK_API_PATH}/resources/:resourceType/:resourceId`, + async (req: Request, res: Response, next: NextFunction) => { + debug( + `** DELETE ${SIGNALK_API_PATH}/resources/:resourceType/:resourceId` + ) + if ( + !this.checkForProvider(req.params.resourceType as SignalKResourceType) + ) { + debug('** No provider found... calling next()...') + next() + return + } + + if (!this.updateAllowed()) { + res.status(403) + return + } + try { + const retVal = await this.resProvider[ + req.params.resourceType + ]?.deleteResource(req.params.resourceType, req.params.resourceId) + + this.server.handleMessage( + this.resProvider[req.params.resourceType]?.pluginId as string, + this.buildDeltaMsg( + req.params.resourceType as SignalKResourceType, + req.params.resourceId, + null + ) + ) + res.status(200).send(`Resource (${req.params.resourceId}) deleted.`) + } catch (err) { + res + .status(400) + .send(`Error deleting resource (${req.params.resourceId})!`) + } + } + ) + + // facilitate API requests + this.server.post( + `${SIGNALK_API_PATH}/resources/set/:resourceType`, + async (req: Request, res: Response) => { + debug(`** POST ${SIGNALK_API_PATH}/resources/set/:resourceType`) + + if (!this.updateAllowed()) { + res.status(403) + return + } + + let apiData = this.processApiRequest(req) + debug(apiData) + + if (!this.checkForProvider(apiData.type)) { + res.status(501).send(`No provider for ${apiData.type}!`) + return + } + if (!apiData.value) { + res.status(406).send(`Invalid resource data supplied!`) + return + } + if (apiData.type === 'charts') { + if (!validate.chartId(apiData.id)) { + res.status(406).send(`Invalid chart resource id supplied!`) + return + } + } else { + if (!validate.uuid(apiData.id)) { + res.status(406).send(`Invalid resource id supplied!`) + return + } + } + + try { + await this.resProvider[apiData.type]?.setResource( + apiData.type, + apiData.id, + apiData.value + ) + this.server.handleMessage( + this.resProvider[apiData.type]?.pluginId as string, + this.buildDeltaMsg(apiData.type, apiData.id, apiData.value) + ) + res.status(200).send(`SUCCESS: New ${req.params.resourceType} resource created.`) + } catch (err) { + res.status(404).send(`ERROR: Could not create ${req.params.resourceType} resource!`) + } + } + ) + this.server.put( + `${SIGNALK_API_PATH}/resources/set/:resourceType/:resourceId`, + async (req: Request, res: Response) => { + debug( + `** PUT ${SIGNALK_API_PATH}/resources/set/:resourceType/:resourceId` + ) + + if (!this.updateAllowed()) { + res.status(403) + return + } + + const apiData = this.processApiRequest(req) + + if (!this.checkForProvider(apiData.type)) { + res.status(501).send(`No provider for ${apiData.type}!`) + return + } + if (!apiData.value) { + res.status(406).send(`Invalid resource data supplied!`) + return + } + if (apiData.type === 'charts') { + if (!validate.chartId(apiData.id)) { + res.status(406).send(`Invalid chart resource id supplied!`) + return + } + } else { + if (!validate.uuid(apiData.id)) { + res.status(406).send(`Invalid resource id supplied!`) + return + } + } + + try { + await this.resProvider[apiData.type]?.setResource( + apiData.type, + apiData.id, + apiData.value + ) + this.server.handleMessage( + this.resProvider[apiData.type]?.pluginId as string, + this.buildDeltaMsg(apiData.type, apiData.id, apiData.value) + ) + res.status(200).send(`SUCCESS: ${req.params.resourceType} resource updated.`) + } catch (err) { + res.status(404).send(`ERROR: ${req.params.resourceType} resource could not be updated!`) + } + } + ) + } + + private processApiRequest(req: Request) { + let apiReq: any = { + type: undefined, + id: undefined, + value: undefined + } + + if (req.params.resourceType.toLowerCase() === 'waypoint') { + apiReq.type = 'waypoints' + } + if (req.params.resourceType.toLowerCase() === 'route') { + apiReq.type = 'routes' + } + if (req.params.resourceType.toLowerCase() === 'note') { + apiReq.type = 'notes' + } + if (req.params.resourceType.toLowerCase() === 'region') { + apiReq.type = 'regions' + } + if (req.params.resourceType.toLowerCase() === 'charts') { + apiReq.type = 'charts' + } + + apiReq.value = buildResource(apiReq.type, req.body) + + apiReq.id = req.params.resourceId + ? req.params.resourceId + : (apiReq.type === 'charts' ? apiReq.value.identifier : UUID_PREFIX + uuidv4()) + + return apiReq + } + + private getResourcePaths(): { [key: string]: any } { + const resPaths: { [key: string]: any } = {} + for (const i in this.resProvider) { + if (this.resProvider.hasOwnProperty(i)) { + resPaths[i] = { + description: `Path containing ${ + i.slice(-1) === 's' ? i.slice(0, i.length - 1) : i + } resources`, + $source: this.resProvider[i]?.pluginId + } + } + } + return resPaths + } + + private checkForProvider(resType: SignalKResourceType): boolean { + debug(`** checkForProvider(${resType})`) + debug(this.resProvider[resType]) + return (this.resProvider[resType]) ? true : false + } + + private buildDeltaMsg( + resType: SignalKResourceType, + resid: string, + resValue: any + ): any { + return { + updates: [ + { + values: [ + { + path: `resources.${resType}.${resid}`, + value: resValue + } + ] + } + ] + } + } +} diff --git a/src/api/resources/openApi.json b/src/api/resources/openApi.json new file mode 100644 index 000000000..7bc1abc8a --- /dev/null +++ b/src/api/resources/openApi.json @@ -0,0 +1,2130 @@ +{ + "openapi": "3.0.2", + "info": { + "version": "1.0.0", + "title": "Signal K Resources API" + }, + + "paths": { + + "/resources": { + "get": { + "tags": ["resources"], + "summary": "List available resource types", + "responses": { + "200": { + "description": "List of available resource types", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + } + } + }, + + "/resources/{resourceType}": { + "get": { + "tags": ["resources"], + "summary": "Retrieve resources", + "parameters": [ + { + "name": "resourceType", + "in": "path", + "description": "Type of resources to retrieve. Valid values are: routes, waypoints, notes, regions, charts", + "required": true, + "schema": { + "type": "string", + "enum": ["routes", "waypoints", "notes", "regions", "charts"], + "example": "waypoints" + } + }, + { + "name": "limit", + "in": "query", + "description": "Maximum number of records to return", + "schema": { + "type": "integer", + "format": "int32", + "minimum": 1, + "example": 100 + } + }, + { + "name": "distance", + "in": "query", + "description": "Limit results to resources that fall within a square area, centered around the vessel's position, the edges of which are the sepecified distance in meters from the vessel.", + "schema": { + "type": "integer", + "format": "int32", + "minimum": 100, + "example": 2000 + } + }, + { + "name": "bbox", + "in": "query", + "description": "Limit results to resources that fall within the bounded area defined as lower left (south west) and upper right (north east) coordinates [swlon,swlat,nelon,nelat]", + "style": "form", + "explode": false, + "schema": { + "type": "array", + "minItems": 4, + "maxItems": 4, + "items": { + "type": "number", + "format": "float", + "example": [135.5,-25.2,138.1,-28.0] + } + } + } + ], + "responses": { + "200": { + "description": "List of resources identified by their UUID", + "content": { + "application/json": { + "schema": { + "description": "List of Signal K resources", + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + } + } + }, + + "/resources/routes/": { + "post": { + "tags": ["resources/routes"], + "summary": "Add a new Route", + "requestBody": { + "description": "Route details", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Signal K Route resource", + "required": ["feature"], + "properties": { + "name": { + "type": "string", + "description": "Route's common name" + }, + "description": { + "type": "string", + "description": "A description of the route" + }, + "distance": { + "description": "Total distance from start to end", + "type": "number" + }, + "start": { + "description": "The waypoint UUID at the start of the route", + "type": "string", + "pattern": "/resources/waypoints/urn:mrn:signalk:uuid:[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-4[0-9A-Fa-f]{3}-[89ABab][0-9A-Fa-f]{3}-[0-9A-Fa-f]{12}$" + }, + "end": { + "description": "The waypoint UUID at the end of the route", + "type": "string", + "pattern": "/resources//waypoints/urn:mrn:signalk:uuid:[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-4[0-9A-Fa-f]{3}-[89ABab][0-9A-Fa-f]{3}-[0-9A-Fa-f]{12}$" + }, + "feature": { + "type": "object", + "title": "Feature", + "description": "A GeoJSON feature object which describes a route", + "properties": { + "geometry": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["LineString"] + }, + "coordinates": { + "type": "array", + "items": { + "type": "array", + "maxItems": 3, + "minItems": 2, + "items": { + "type": "number" + } + } + } + } + }, + "properties": { + "description": "Additional feature properties", + "type": "object", + "additionalProperties": true + }, + "id": { + "type": "string" + } + } + } + } + + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + + "/resources/routes/{id}": { + "parameters": { + "name": "id", + "in": "path", + "description": "route id", + "required": true, + "schema": { + "type": "string", + "pattern": "urn:mrn:signalk:uuid:[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-4[0-9A-Fa-f]{3}-[89ABab][0-9A-Fa-f]{3}-[0-9A-Fa-f]{12}$" + } + }, + + "get": { + "tags": ["resources/routes"], + "summary": "Retrieve route with supplied id", + "responses": { + "200": { + "description": "List of resources identified by their UUID", + "content": { + "application/json": { + "schema": { + "description": "List of Signal K resources", + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + } + }, + + "put": { + "tags": ["resources/routes"], + "summary": "Add / update a new Route with supplied id", + "requestBody": { + "description": "Route details", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Signal K Route resource", + "required": ["feature"], + "properties": { + "name": { + "type": "string", + "description": "Route's common name" + }, + "description": { + "type": "string", + "description": "A description of the route" + }, + "distance": { + "description": "Total distance from start to end", + "type": "number" + }, + "start": { + "description": "The waypoint UUID at the start of the route", + "type": "string", + "pattern": "/resources/waypoints/urn:mrn:signalk:uuid:[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-4[0-9A-Fa-f]{3}-[89ABab][0-9A-Fa-f]{3}-[0-9A-Fa-f]{12}$" + }, + "end": { + "description": "The waypoint UUID at the end of the route", + "type": "string", + "pattern": "/resources/waypoints/urn:mrn:signalk:uuid:[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-4[0-9A-Fa-f]{3}-[89ABab][0-9A-Fa-f]{3}-[0-9A-Fa-f]{12}$" + }, + "feature": { + "type": "object", + "title": "Feature", + "description": "A GeoJSON feature object which describes a route", + "properties": { + "geometry": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["LineString"] + }, + "coordinates": { + "type": "array", + "items": { + "type": "array", + "maxItems": 3, + "minItems": 2, + "items": { + "type": "number" + } + } + } + } + }, + "properties": { + "description": "Additional feature properties", + "type": "object", + "additionalProperties": true + }, + "id": { + "type": "string" + } + } + } + } + + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + } + } + }, + + "delete": { + "tags": ["resources/routes"], + "summary": "Remove Route with supplied id", + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + } + } + } + + }, + + "/resources/waypoints/": { + "post": { + "tags": ["resources/waypoints"], + "summary": "Add a new Waypoint", + "requestBody": { + "description": "Waypoint details", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Signal K Waypoint resource", + "required": ["feature"], + "properties": { + "position": { + "description": "The waypoint position", + "type": "object", + "required": [ + "latitude", + "longitude" + ], + "properties": { + "latitude": { + "type": "number", + "format": "float" + }, + "longitude": { + "type": "number", + "format": "float" + }, + "altitude": { + "type": "number", + "format": "float" + } + } + }, + "feature": { + "type": "object", + "title": "Feature", + "description": "A GeoJSON feature object which describes a waypoint", + "properties": { + "geometry": { + "type": "object", + "properties": { + "type": "object", + "description": "GeoJSon geometry", + "properties": { + "type": { + "type": "string", + "enum": ["Point"] + } + }, + "coordinates": { + "type": "array", + "maxItems": 3, + "minItems": 2, + "items": { + "type": "number" + } + } + } + }, + "properties": { + "description": "Additional feature properties", + "type": "object", + "additionalProperties": true + }, + "id": { + "type": "string" + } + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + + "/resources/waypoints/{id}": { + "parameters": { + "name": "id", + "in": "path", + "description": "waypoint id", + "required": true, + "schema": { + "type": "string", + "pattern": "urn:mrn:signalk:uuid:[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-4[0-9A-Fa-f]{3}-[89ABab][0-9A-Fa-f]{3}-[0-9A-Fa-f]{12}$" + } + }, + + "get": { + "tags": ["resources/waypoints"], + "summary": "Retrieve waypoint with supplied id", + "responses": { + "200": { + "description": "List of resources identified by their UUID", + "content": { + "application/json": { + "schema": { + "description": "List of Signal K resources", + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + } + }, + + "put": { + "tags": ["resources/waypoints"], + "summary": "Add / update a new Waypoint with supplied id", + "requestBody": { + "description": "Waypoint details", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Signal K Waypoint resource", + "required": ["feature"], + "properties": { + "position": { + "description": "The waypoint position", + "type": "object", + "required": [ + "latitude", + "longitude" + ], + "properties": { + "latitude": { + "type": "number", + "format": "float" + }, + "longitude": { + "type": "number", + "format": "float" + }, + "altitude": { + "type": "number", + "format": "float" + } + } + }, + "feature": { + "type": "object", + "title": "Feature", + "description": "A GeoJSON feature object which describes a waypoint", + "properties": { + "geometry": { + "type": "object", + "properties": { + "type": "object", + "description": "GeoJSon geometry", + "properties": { + "type": { + "type": "string", + "enum": ["Point"] + } + }, + "coordinates": { + "type": "array", + "maxItems": 3, + "minItems": 2, + "items": { + "type": "number" + } + } + } + }, + "properties": { + "description": "Additional feature properties", + "type": "object", + "additionalProperties": true + }, + "id": { + "type": "string" + } + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + } + } + }, + + "delete": { + "tags": ["resources/waypoints"], + "summary": "Remove Waypoint with supplied id", + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + } + } + } + + }, + + "/resources/notes/": { + "post": { + "tags": ["resources/notes"], + "summary": "Add a new Note", + "requestBody": { + "description": "Note details", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Signal K Note resource", + "required": ["feature"], + "properties": { + "title": { + "type": "string", + "description": "Common Note name" + }, + "description": { + "type": "string", + "description": "A description of the note" + }, + "position": { + "description": "Position related to note. Alternative to region or geohash.", + "type": "object", + "required": [ + "latitude", + "longitude" + ], + "properties": { + "latitude": { + "type": "number", + "format": "float" + }, + "longitude": { + "type": "number", + "format": "float" + }, + "altitude": { + "type": "number", + "format": "float" + } + } + }, + "region": { + "description": "Pointer / path to Region related to note (e.g. /resources/routes/{uuid}. Alternative to position or geohash", + "type": "string", + "pattern": "/resources/regions/urn:mrn:signalk:uuid:[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-4[0-9A-Fa-f]{3}-[89ABab][0-9A-Fa-f]{3}-[0-9A-Fa-f]{12}$" + }, + "geohash": { + "description": "Area related to note. Alternative to region or position", + "type": "string" + }, + "mimeType": { + "description": "MIME type of the note", + "type": "string" + }, + "url": { + "description": "Location of the note content", + "type": "string" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + + "/resources/notes/{id}": { + "parameters": { + "name": "id", + "in": "path", + "description": "note id", + "required": true, + "schema": { + "type": "string", + "pattern": "urn:mrn:signalk:uuid:[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-4[0-9A-Fa-f]{3}-[89ABab][0-9A-Fa-f]{3}-[0-9A-Fa-f]{12}$" + } + }, + + "get": { + "tags": ["resources/notes"], + "summary": "Retrieve Note with supplied id", + "responses": { + "200": { + "description": "List of resources identified by their UUID", + "content": { + "application/json": { + "schema": { + "description": "List of Signal K resources", + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + } + }, + + "put": { + "tags": ["resources/notes"], + "summary": "Add / update a new Note with supplied id", + "requestBody": { + "description": "Note details", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Signal K Note resource", + "required": ["feature"], + "properties": { + "title": { + "type": "string", + "description": "Common Note name" + }, + "description": { + "type": "string", + "description": "A description of the note" + }, + "position": { + "description": "Position related to note. Alternative to region or geohash.", + "type": "object", + "required": [ + "latitude", + "longitude" + ], + "properties": { + "latitude": { + "type": "number", + "format": "float" + }, + "longitude": { + "type": "number", + "format": "float" + }, + "altitude": { + "type": "number", + "format": "float" + } + } + }, + "region": { + "description": "Pointer / path to Region related to note (e.g. /resources/routes/{uuid}. Alternative to position or geohash", + "type": "string", + "pattern": "/resources/regions/urn:mrn:signalk:uuid:[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-4[0-9A-Fa-f]{3}-[89ABab][0-9A-Fa-f]{3}-[0-9A-Fa-f]{12}$" + }, + "geohash": { + "description": "Area related to note. Alternative to region or position", + "type": "string" + }, + "mimeType": { + "description": "MIME type of the note", + "type": "string" + }, + "url": { + "description": "Location of the note content", + "type": "string" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + } + } + }, + + "delete": { + "tags": ["resources/notes"], + "summary": "Remove Note with supplied id", + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + } + } + } + + }, + + "/resources/regions/": { + "post": { + "tags": ["resources/regions"], + "summary": "Add a new Region", + "requestBody": { + "description": "Region details", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Signal K Region resource", + "required": ["feature"], + "properties": { + "geohash": { + "description": "geohash of the approximate boundary of this region", + "type": "string" + }, + "feature": { + "type": "object", + "title": "Feature", + "description": "A Geo JSON feature object which describes the regions boundary", + "properties": { + "geometry": { + "type": "object", + "properties": { + "oneOf": [ + { + "type": "object", + "description": "GeoJSon geometry", + "properties": { + "type": { + "type": "string", + "enum": ["Polygon"] + }, + "coordinates": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "array", + "maxItems": 3, + "minItems": 2, + "items": { + "type": "number" + } + } + } + } + } + }, + { + "type": "object", + "description": "GeoJSon geometry", + "properties": { + "type": { + "type": "string", + "enum": ["MultiPolygon"] + }, + "coordinates": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "array", + "maxItems": 3, + "minItems": 2, + "items": { + "type": "number" + } + } + } + } + } + } + } + ] + } + }, + "properties": { + "description": "Additional feature properties", + "type": "object", + "additionalProperties": true + }, + "id": { + "type": "string" + } + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + + "/resources/regions/{id}": { + "parameters": { + "name": "id", + "in": "path", + "description": "region id", + "required": true, + "schema": { + "type": "string", + "pattern": "urn:mrn:signalk:uuid:[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-4[0-9A-Fa-f]{3}-[89ABab][0-9A-Fa-f]{3}-[0-9A-Fa-f]{12}$" + } + }, + + "get": { + "tags": ["resources/regions"], + "summary": "Retrieve Region with supplied id", + "responses": { + "200": { + "description": "List of resources identified by their UUID", + "content": { + "application/json": { + "schema": { + "description": "List of Signal K resources", + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + } + }, + + "put": { + "tags": ["resources/regions"], + "summary": "Add / update a new Region with supplied id", + "requestBody": { + "description": "Region details", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Signal K Region resource", + "required": ["feature"], + "properties": { + "geohash": { + "description": "geohash of the approximate boundary of this region", + "type": "string" + }, + "feature": { + "type": "object", + "title": "Feature", + "description": "A Geo JSON feature object which describes the regions boundary", + "properties": { + "geometry": { + "type": "object", + "properties": { + "oneOf": [ + { + "type": "object", + "description": "GeoJSon geometry", + "properties": { + "type": { + "type": "string", + "enum": ["Polygon"] + }, + "coordinates": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "array", + "maxItems": 3, + "minItems": 2, + "items": { + "type": "number" + } + } + } + } + } + }, + { + "type": "object", + "description": "GeoJSon geometry", + "properties": { + "type": { + "type": "string", + "enum": ["MultiPolygon"] + }, + "coordinates": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "array", + "maxItems": 3, + "minItems": 2, + "items": { + "type": "number" + } + } + } + } + } + } + } + ] + } + }, + "properties": { + "description": "Additional feature properties", + "type": "object", + "additionalProperties": true + }, + "id": { + "type": "string" + } + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + } + } + }, + + "delete": { + "tags": ["resources/regions"], + "summary": "Remove Region with supplied id", + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + } + } + } + + }, + + "/resources/charts/": { + "post": { + "tags": ["resources/charts"], + "summary": "Add a new Chart", + "requestBody": { + "description": "Chart details", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Signal K Chart resource", + "required": ["feature"], + "properties": { + "name": { + "description": "Chart common name", + "example":"NZ615 Marlborough Sounds", + "type": "string" + }, + "identifier": { + "type": "string", + "description": "Chart number", + "example":"NZ615" + }, + "description": { + "type": "string", + "description": "A description of the chart" + }, + "tilemapUrl": { + "type": "string", + "description": "A url to the tilemap of the chart for use in TMS chartplotting apps", + "example":"http://{server}:8080/mapcache/NZ615" + }, + "region": { + "type": "string", + "description": "Region related to note. A pointer to a region UUID. Alternative to geohash" + }, + "geohash": { + "description": "Position related to chart. Alternative to region", + "type": "string" + }, + "chartUrl": { + "type": "string", + "description": "A url to the chart file's storage location", + "example":"file:///home/pi/freeboard/mapcache/NZ615" + }, + "scale": { + "type": "integer", + "description": "The scale of the chart, the larger number from 1:200000" + }, + "chartLayers": { + "type": "array", + "description": "If the chart format is WMS, the layers enabled for the chart.", + "items": { + "type": "string", + "description": "Identifier for the layer." + } + }, + "bounds": { + "type": "array", + "description": "The bounds of the chart. An array containing the position of the upper left corner, and the lower right corner. Useful when the chart isn't inherently geo-referenced.", + "items": { + "description": "Position of a corner of the chart", + "type": "object", + "required": [ + "latitude", + "longitude" + ], + "properties": { + "latitude": { + "type": "number", + "format": "float" + }, + "longitude": { + "type": "number", + "format": "float" + }, + "altitude": { + "type": "number", + "format": "float" + } + } + } + }, + "chartFormat": { + "type": "string", + "description": "The format of the chart", + "enum": [ + "gif", + "geotiff", + "kap", + "png", + "jpg", + "kml", + "wkt", + "topojson", + "geojson", + "gpx", + "tms", + "wms", + "S-57", + "S-63", + "svg", + "other" + ] + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + + "/resources/charts/{id}": { + "parameters": { + "name": "id", + "in": "path", + "description": "Chart id", + "required": true, + "schema": { + "type": "string", + "pattern": "(^[A-Za-z0-9_-]{8,}$)" + } + }, + + "get": { + "tags": ["resources/charts"], + "summary": "Retrieve Chart with supplied id", + "responses": { + "200": { + "description": "List of resources identified by their UUID", + "content": { + "application/json": { + "schema": { + "description": "List of Signal K resources", + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + } + }, + + "put": { + "tags": ["resources/charts"], + "summary": "Add / update a new Chart with supplied id", + "requestBody": { + "description": "Chart details", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Signal K Chart resource", + "required": ["feature"], + "properties": { + "name": { + "description": "Chart common name", + "example":"NZ615 Marlborough Sounds", + "type": "string" + }, + "identifier": { + "type": "string", + "description": "Chart number", + "example":"NZ615" + }, + "description": { + "type": "string", + "description": "A description of the chart" + }, + "tilemapUrl": { + "type": "string", + "description": "A url to the tilemap of the chart for use in TMS chartplotting apps", + "example":"http://{server}:8080/mapcache/NZ615" + }, + "region": { + "type": "string", + "description": "Region related to note. A pointer to a region UUID. Alternative to geohash" + }, + "geohash": { + "description": "Position related to chart. Alternative to region", + "type": "string" + }, + "chartUrl": { + "type": "string", + "description": "A url to the chart file's storage location", + "example":"file:///home/pi/freeboard/mapcache/NZ615" + }, + "scale": { + "type": "integer", + "description": "The scale of the chart, the larger number from 1:200000" + }, + "chartLayers": { + "type": "array", + "description": "If the chart format is WMS, the layers enabled for the chart.", + "items": { + "type": "string", + "description": "Identifier for the layer." + } + }, + "bounds": { + "type": "array", + "description": "The bounds of the chart. An array containing the position of the upper left corner, and the lower right corner. Useful when the chart isn't inherently geo-referenced.", + "items": { + "description": "Position of a corner of the chart", + "type": "object", + "required": [ + "latitude", + "longitude" + ], + "properties": { + "latitude": { + "type": "number", + "format": "float" + }, + "longitude": { + "type": "number", + "format": "float" + }, + "altitude": { + "type": "number", + "format": "float" + } + } + } + }, + "chartFormat": { + "type": "string", + "description": "The format of the chart", + "enum": [ + "gif", + "geotiff", + "kap", + "png", + "jpg", + "kml", + "wkt", + "topojson", + "geojson", + "gpx", + "tms", + "wms", + "S-57", + "S-63", + "svg", + "other" + ] + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + } + } + }, + + "delete": { + "tags": ["resources/charts"], + "summary": "Remove Chart with supplied id", + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + } + } + } + + }, + + "/resources/set/waypoint": { + "post": { + "tags": ["resources/api"], + "summary": "Add / update a Waypoint", + "requestBody": { + "description": "Waypoint attributes", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["position"], + "properties": { + "name": { + "type": "string", + "description": "Waypoint name" + }, + "description": { + "type": "string", + "description": "Textual description of the waypoint" + }, + "attributes": { + "type": "object", + "description": "Additional attributes as name:value pairs.", + "additionalProperties": { + "type": "string" + } + }, + "position": { + "description": "The waypoint position", + "type": "object", + "required": [ + "latitude", + "longitude" + ], + "properties": { + "latitude": { + "type": "number", + "format": "float" + }, + "longitude": { + "type": "number", + "format": "float" + } + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + + "/resources/set/waypoint/{id}": { + "put": { + "tags": ["resources/api"], + "summary": "Add / update a Waypoint", + "requestBody": { + "description": "Waypoint attributes", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["position"], + "properties": { + "name": { + "type": "string", + "description": "Waypoint name" + }, + "description": { + "type": "string", + "description": "Textual description of the waypoint" + }, + "attributes": { + "type": "object", + "description": "Additional attributes as name:value pairs.", + "additionalProperties": { + "type": "string" + } + }, + "position": { + "description": "The waypoint position", + "type": "object", + "required": [ + "latitude", + "longitude" + ], + "properties": { + "latitude": { + "type": "number", + "format": "float" + }, + "longitude": { + "type": "number", + "format": "float" + } + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + + "/resources/set/route": { + "post": { + "tags": ["resources/api"], + "summary": "Add / update a Route", + "requestBody": { + "description": "Note attributes", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["position"], + "properties": { + "name": { + "type": "string", + "description": "Route name" + }, + "description": { + "type": "string", + "description": "Textual description of the route" + }, + "attributes": { + "type": "object", + "description": "Additional attributes as name:value pairs.", + "additionalProperties": { + "type": "string" + } + }, + "points": { + "description": "Route points", + "type": "array", + "items": { + "type": "object", + "required": [ + "latitude", + "longitude" + ], + "properties": { + "latitude": { + "type": "number", + "format": "float" + }, + "longitude": { + "type": "number", + "format": "float" + } + } + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + + "/resources/set/route/{id}": { + "put": { + "tags": ["resources/api"], + "summary": "Add / update a Route", + "requestBody": { + "description": "Note attributes", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["position"], + "properties": { + "name": { + "type": "string", + "description": "Route name" + }, + "description": { + "type": "string", + "description": "Textual description of the route" + }, + "attributes": { + "type": "object", + "description": "Additional attributes as name:value pairs.", + "additionalProperties": { + "type": "string" + } + }, + "points": { + "description": "Route points", + "type": "array", + "items": { + "type": "object", + "required": [ + "latitude", + "longitude" + ], + "properties": { + "latitude": { + "type": "number", + "format": "float" + }, + "longitude": { + "type": "number", + "format": "float" + } + } + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + + "/resources/set/note": { + "post": { + "tags": ["resources/api"], + "summary": "Add / update a Note", + "requestBody": { + "description": "Note attributes", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["title"], + "properties": { + "title": { + "type": "string", + "description": "Note's common name" + }, + "description": { + "type": "string", + "description": " Textual description of the note" + }, + "oneOf":[ + { + "position": { + "description": "Position related to note. Alternative to region or geohash", + "type": "object", + "required": [ + "latitude", + "longitude" + ], + "properties": { + "latitude": { + "type": "number", + "format": "float" + }, + "longitude": { + "type": "number", + "format": "float" + } + } + }, + "region": { + "type": "string", + "description": "Region related to note. A pointer to a region UUID. Alternative to position or geohash", + "example": "/resources/regions/urn:mrn:signalk:uuid:ac3a3b2d-07e8-4f25-92bc-98e7c92f7f1a" + } + } + ], + "mimeType": { + "type": "string", + "description": "MIME type of the note" + }, + "url": { + "type": "string", + "description": "Location of the note contents" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + + "/resources/set/note/{id}": { + "put": { + "tags": ["resources/api"], + "summary": "Add / update a Note", + "requestBody": { + "description": "Note attributes", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["title"], + "properties": { + "title": { + "type": "string", + "description": "Note's common name" + }, + "description": { + "type": "string", + "description": " Textual description of the note" + }, + "oneOf":[ + { + "position": { + "description": "Position related to note. Alternative to region or geohash", + "type": "object", + "required": [ + "latitude", + "longitude" + ], + "properties": { + "latitude": { + "type": "number", + "format": "float" + }, + "longitude": { + "type": "number", + "format": "float" + } + } + }, + "region": { + "type": "string", + "description": "Region related to note. A pointer to a region UUID. Alternative to position or geohash", + "example": "/resources/regions/urn:mrn:signalk:uuid:ac3a3b2d-07e8-4f25-92bc-98e7c92f7f1a" + } + } + ], + "mimeType": { + "type": "string", + "description": "MIME type of the note" + }, + "url": { + "type": "string", + "description": "Location of the note contents" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + + "/resources/set/region": { + "post": { + "tags": ["resources/api"], + "summary": "Add / update a Region", + "requestBody": { + "description": "Region attributes", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["position"], + "properties": { + "name": { + "type": "string", + "description": "Region name" + }, + "description": { + "type": "string", + "description": "Textual description of region" + }, + "attributes": { + "type": "object", + "description": "Additional attributes as name:value pairs.", + "additionalProperties": { + "type": "string" + } + }, + "points": { + "description": "Region boundary points", + "type": "array", + "items": { + "type": "object", + "required": [ + "latitude", + "longitude" + ], + "properties": { + "latitude": { + "type": "number", + "format": "float" + }, + "longitude": { + "type": "number", + "format": "float" + } + } + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + + "/resources/set/region/{id}": { + "put": { + "tags": ["resources/api"], + "summary": "Add / update a Region", + "requestBody": { + "description": "Region attributes", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["position"], + "properties": { + "name": { + "type": "string", + "description": "Region name" + }, + "description": { + "type": "string", + "description": "Textual description of region" + }, + "attributes": { + "type": "object", + "description": "Additional attributes as name:value pairs.", + "additionalProperties": { + "type": "string" + } + }, + "points": { + "description": "Region boundary points", + "type": "array", + "items": { + "type": "object", + "required": [ + "latitude", + "longitude" + ], + "properties": { + "latitude": { + "type": "number", + "format": "float" + }, + "longitude": { + "type": "number", + "format": "float" + } + } + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + + "/resources/set/chart": { + "post": { + "tags": ["resources/api"], + "summary": "Add / update a Chart", + "requestBody": { + "description": "Chart attributes", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Signal K Chart resource", + "required": ["identifier", "tilemapUrl"], + "properties": { + "identifier": { + "type": "string", + "description": "Chart number", + "example":"NZ615" + }, + "name": { + "description": "Chart common name", + "example":"NZ615 Marlborough Sounds", + "type": "string" + }, + "description": { + "type": "string", + "description": "A description of the chart" + }, + "tilemapUrl": { + "type": "string", + "description": "A url to the tilemap of the chart for use in TMS chartplotting apps", + "example":"http://{server}:8080/mapcache/NZ615" + }, + "scale": { + "type": "integer", + "description": "The scale of the chart, the larger number from 1:200000" + }, + "layers": { + "type": "array", + "description": "If the chart format is WMS, the layers enabled for the chart.", + "items": { + "type": "string", + "description": "Identifier for the layer." + } + }, + "bounds": { + "type": "array", + "description": "The bounding rectangle of the chart defined by the position of the lower left corner, and the upper right corner.", + "items": { + "description": "Position of a corner of the chart", + "type": "object", + "required": [ + "latitude", + "longitude" + ], + "properties": { + "latitude": { + "type": "number", + "format": "float" + }, + "longitude": { + "type": "number", + "format": "float" + } + } + } + }, + "format": { + "type": "string", + "description": "The format of the chart", + "enum": [ + "png", + "jpg" + ] + }, + "serverType": { + "type": "string", + "description": "Chart server type", + "enum": [ + "tilelayer", + "WMS" + ] + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + + "/resources/set/chart/{id}": { + "put": { + "tags": ["resources/api"], + "summary": "Add / update a Chart", + "requestBody": { + "description": "Chart attributes", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Signal K Chart resource", + "required": ["identifier", "tilemapUrl"], + "properties": { + "identifier": { + "type": "string", + "description": "Chart number", + "example":"NZ615" + }, + "name": { + "description": "Chart common name", + "example":"NZ615 Marlborough Sounds", + "type": "string" + }, + "description": { + "type": "string", + "description": "A description of the chart" + }, + "tilemapUrl": { + "type": "string", + "description": "A url to the tilemap of the chart for use in TMS chartplotting apps", + "example":"http://{server}:8080/mapcache/NZ615" + }, + "scale": { + "type": "integer", + "description": "The scale of the chart, the larger number from 1:200000" + }, + "layers": { + "type": "array", + "description": "If the chart format is WMS, the layers enabled for the chart.", + "items": { + "type": "string", + "description": "Identifier for the layer." + } + }, + "bounds": { + "type": "array", + "description": "The bounding rectangle of the chart defined by the position of the lower left corner, and the upper right corner.", + "items": { + "description": "Position of a corner of the chart", + "type": "object", + "required": [ + "latitude", + "longitude" + ], + "properties": { + "latitude": { + "type": "number", + "format": "float" + }, + "longitude": { + "type": "number", + "format": "float" + } + } + } + }, + "format": { + "type": "string", + "description": "The format of the chart", + "enum": [ + "png", + "jpg" + ] + }, + "serverType": { + "type": "string", + "description": "Chart server type", + "enum": [ + "tilelayer", + "WMS" + ] + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + } + } + } + } + + } + +} + \ No newline at end of file diff --git a/src/api/resources/resources.ts b/src/api/resources/resources.ts new file mode 100644 index 000000000..36ee48b21 --- /dev/null +++ b/src/api/resources/resources.ts @@ -0,0 +1,267 @@ +import { SignalKResourceType } from '@signalk/server-api' +import { getDistance, getLatitude, isValidCoordinate } from 'geolib' + +export const buildResource = (resType: SignalKResourceType, data: any): any => { + if (resType === 'routes') { + return buildRoute(data) + } + if (resType === 'waypoints') { + return buildWaypoint(data) + } + if (resType === 'notes') { + return buildNote(data) + } + if (resType === 'regions') { + return buildRegion(data) + } + if (resType === 'charts') { + return buildChart(data) + } + return null +} + +const buildRoute = (rData: any): any => { + const rte: any = { + feature: { + type: 'Feature', + geometry: { + type: 'LineString', + coordinates: [] + }, + properties: {} + } + } + if (typeof rData.name !== 'undefined') { + rte.name = rData.name + rte.feature.properties.name = rData.name + } + if (typeof rData.description !== 'undefined') { + rte.description = rData.description + rte.feature.properties.description = rData.description + } + if (typeof rData.attributes !== 'undefined') { + Object.assign(rte.feature.properties, rData.attributes) + } + + if (typeof rData.points === 'undefined') { + return null + } + if (!Array.isArray(rData.points)) { + return null + } + let isValid: boolean = true + rData.points.forEach((p: any) => { + if (!isValidCoordinate(p)) { + isValid = false + } + }) + if (!isValid) { + return null + } + rte.feature.geometry.coordinates = rData.points.map((p: any) => { + return [p.longitude, p.latitude] + }) + + rte.distance = 0 + for (let i = 0; i < rData.points.length; i++) { + if (i !== 0) { + rte.distance = + rte.distance + getDistance(rData.points[i - 1], rData.points[i]) + } + } + return rte +} + +const buildWaypoint = (rData: any): any => { + const wpt: any = { + position: { + latitude: 0, + longitude: 0 + }, + feature: { + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [] + }, + properties: {} + } + } + if (typeof rData.name !== 'undefined') { + wpt.feature.properties.name = rData.name + } + if (typeof rData.description !== 'undefined') { + wpt.feature.properties.description = rData.description + } + if (typeof rData.attributes !== 'undefined') { + Object.assign(wpt.feature.properties, rData.attributes) + } + + if (typeof rData.position === 'undefined') { + return null + } + if (!isValidCoordinate(rData.position)) { + return null + } + + wpt.position = rData.position + wpt.feature.geometry.coordinates = [ + rData.position.longitude, + rData.position.latitude + ] + + return wpt +} + +const buildNote = (rData: any): any => { + const note: any = {} + if (typeof rData.title !== 'undefined') { + note.title = rData.title + note.feature.properties.title = rData.title + } + if (typeof rData.description !== 'undefined') { + note.description = rData.description + note.feature.properties.description = rData.description + } + if ( + typeof rData.position === 'undefined' && + typeof rData.href === 'undefined' + ) { + return null + } + + if (typeof rData.position !== 'undefined') { + if (!isValidCoordinate(rData.position)) { + return null + } + note.position = rData.position + } + if (typeof rData.href !== 'undefined') { + note.region = rData.href + } + if (typeof rData.url !== 'undefined') { + note.url = rData.url + } + if (typeof rData.mimeType !== 'undefined') { + note.mimeType = rData.mimeType + } + + return note +} + +const buildRegion = (rData: any): any => { + const reg: any = { + feature: { + type: 'Feature', + geometry: { + type: 'Polygon', + coordinates: [] + }, + properties: {} + } + } + let coords: Array<[number, number]> = [] + + if (typeof rData.name !== 'undefined') { + reg.feature.properties.name = rData.name + } + if (typeof rData.description !== 'undefined') { + reg.feature.properties.description = rData.description + } + if (typeof rData.attributes !== 'undefined') { + Object.assign(reg.feature.properties, rData.attributes) + } + + if (typeof rData.points !== 'undefined') { + return null + } + if (!Array.isArray(rData.points)) { + return null + } + let isValid: boolean = true + rData.points.forEach((p: any) => { + if (!isValidCoordinate(p)) { + isValid = false + } + }) + if (!isValid) { + return null + } + if ( + rData.points[0].latitude !== + rData.points[rData.points.length - 1].latitude && + rData.points[0].longitude !== + rData.points[rData.points.length - 1].longitude + ) { + rData.points.push(rData.points[0]) + } + coords = rData.points.map((p: any) => { + return [p.longitude, p.latitude] + }) + reg.feature.geometry.coordinates.push(coords) + + return reg +} + +const buildChart = (rData: any): any => { + const chart: any = { + identifier: '', + name: '', + description: '', + minzoom: 1, + maxzoom: 28, + type: 'tilelayer', + format: 'png', + tilemapUrl: '', + chartLayers: [], + scale: 250000, + bounds: [-180, -90, 180, 90] + } + + if (typeof rData.identifier === 'undefined') { + return null + } else { + chart.identifier = rData.identifier + } + if (typeof rData.url === 'undefined') { + return null + } else { + chart.tilemapUrl = rData.url + } + if (typeof rData.name !== 'undefined') { + chart.name = rData.name + } else { + chart.name = rData.identifier + } + if (typeof rData.description !== 'undefined') { + chart.description = rData.description + } + if (typeof rData.minZoom === 'number') { + chart.minzoom = rData.minZoom + } + if (typeof rData.maxZoom === 'number') { + chart.maxzoom = rData.maxZoom + } + if (typeof rData.serverType !== 'undefined') { + chart.type = rData.serverType + } + if (typeof rData.format !== 'undefined') { + chart.format = rData.format + } + if (typeof rData.layers !== 'undefined' && Array.isArray(rData.layers)) { + chart.chartLayers = rData.layers + } + if (typeof rData.scale === 'number') { + chart.scale = rData.scale + } + if (typeof rData.bounds !== 'undefined' && Array.isArray(rData.bounds)) { + chart.bounds = [ + rData.bounds[0].longitude, + rData.bounds[0].latitude, + rData.bounds[1].longitude, + rData.bounds[1].latitude + ] + } + + return chart +} diff --git a/src/api/resources/validate.ts b/src/api/resources/validate.ts new file mode 100644 index 000000000..16eb3c9a8 --- /dev/null +++ b/src/api/resources/validate.ts @@ -0,0 +1,142 @@ +import geoJSON from 'geojson-validation' +import { isValidCoordinate } from 'geolib' + +export const validate = { + resource: (type: string, value: any): boolean => { + if (!type) { + return false + } + switch (type) { + case 'routes': + return validateRoute(value) + break + case 'waypoints': + return validateWaypoint(value) + break + case 'notes': + return validateNote(value) + break + case 'regions': + return validateRegion(value) + break + case 'charts': + return validateChart(value) + break + default: + return true + } + }, + + // returns true if id is a valid Signal K UUID + uuid: (id: string): boolean => { + const uuid = RegExp( + '^urn:mrn:signalk:uuid:[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-4[0-9A-Fa-f]{3}-[89ABab][0-9A-Fa-f]{3}-[0-9A-Fa-f]{12}$' + ) + return uuid.test(id) + }, + + // returns true if id is a valid Signal K Chart resource id + chartId: (id: string): boolean => { + const uuid = RegExp('(^[A-Za-z0-9_-]{8,}$)') + return uuid.test(id) + } +} + +const validateRoute = (r: any): boolean => { + if (r.start) { + const l = r.start.split('/') + if (!validate.uuid(l[l.length - 1])) { + return false + } + } + if (r.end) { + const l = r.end.split('/') + if (!validate.uuid(l[l.length - 1])) { + return false + } + } + try { + if (!r.feature || !geoJSON.valid(r.feature)) { + return false + } + if (r.feature.geometry.type !== 'LineString') { + return false + } + } catch (err) { + return false + } + return true +} + +const validateWaypoint = (r: any): boolean => { + if (typeof r.position === 'undefined') { + return false + } + if (!isValidCoordinate(r.position)) { + return false + } + try { + if (!r.feature || !geoJSON.valid(r.feature)) { + return false + } + if (r.feature.geometry.type !== 'Point') { + return false + } + } catch (e) { + return false + } + return true +} + +// validate note data +const validateNote = (r: any): boolean => { + if (!r.region && !r.position && !r.geohash) { + return false + } + if (typeof r.position !== 'undefined') { + if (!isValidCoordinate(r.position)) { + return false + } + } + if (r.region) { + const l = r.region.split('/') + if (!validate.uuid(l[l.length - 1])) { + return false + } + } + return true +} + +const validateRegion = (r: any): boolean => { + if (!r.geohash && !r.feature) { + return false + } + if (r.feature) { + try { + if (!geoJSON.valid(r.feature)) { + return false + } + if ( + r.feature.geometry.type !== 'Polygon' && + r.feature.geometry.type !== 'MultiPolygon' + ) { + return false + } + } catch (e) { + return false + } + } + return true +} + +const validateChart = (r: any): boolean => { + if (!r.name || !r.identifier || !r.chartFormat) { + return false + } + + if (!r.tilemapUrl && !r.chartUrl) { + return false + } + + return true +} diff --git a/src/app.ts b/src/app.ts index 633e3dc34..f00e821f3 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,6 +1,8 @@ import { FullSignalK } from '@signalk/signalk-schema' +import { EventEmitter } from 'events' import { Config } from './config/config' import DeltaCache from './deltacache' +import { SecurityStrategy } from './types' export interface ServerApp { started: boolean @@ -15,17 +17,21 @@ export interface ServerApp { clients: number } -export interface SignalKMessageHub { - emit: any - on: any +export interface WithSignalK { signalk: FullSignalK handleMessage: (id: string, data: any) => void } +export interface SignalKMessageHub extends EventEmitter, WithSignalK {} + export interface WithConfig { config: Config } +export interface WithSecurityStrategy { + securityStrategy: SecurityStrategy +} + export interface SelfIdentity { selfType: string selfId: string diff --git a/src/index.ts b/src/index.ts index 6286c2be9..c8104d1cc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -51,6 +51,8 @@ import { startSecurity } from './security.js' +import { Resources } from './api/resources' + // tslint:disable-next-line: no-var-requires const { StreamBundle } = require('./streambundle') @@ -81,6 +83,8 @@ class Server { require('./serverroutes')(app, saveSecurityConfig, getSecurityConfig) require('./put').start(app) + app.resourcesApi = new Resources(app) + app.signalk = new FullSignalK(app.selfId, app.selfType) app.propertyValues = new PropertyValues() @@ -257,7 +261,7 @@ class Server { const eventDebugs: { [key: string]: Debug.Debugger } = {} const emit = app.emit - app.emit = (eventName: string) => { + app.emit = (eventName: string, ...args: any[]) => { if (eventName !== 'serverlog') { let eventDebug = eventDebugs[eventName] if (!eventDebug) { @@ -266,10 +270,10 @@ class Server { ) } if (eventDebug.enabled) { - eventDebug([...arguments].slice(1)) + eventDebug([...args].slice(1)) } } - emit.apply(app, arguments) + return emit.apply(app, [eventName, ...args]) } this.app.intervals = [] diff --git a/src/put.js b/src/put.js index 8db74fac8..e6e14a965 100644 --- a/src/put.js +++ b/src/put.js @@ -30,6 +30,11 @@ module.exports = { app.deRegisterActionHandler = deRegisterActionHandler app.put(apiPathPrefix + '*', function(req, res, next) { + // ** ignore resources paths ** + if (req.path.split('/')[4] === 'resources') { + next() + return + } let path = String(req.path).replace(apiPathPrefix, '') const value = req.body diff --git a/src/types.ts b/src/types.ts index 4e6f0c5b0..3ab30be25 100644 --- a/src/types.ts +++ b/src/types.ts @@ -14,6 +14,12 @@ export interface SecurityStrategy { allowReadOnly: () => boolean shouldFilterDeltas: () => boolean filterReadDelta: (user: any, delta: any) => any + shouldAllowPut: ( + req: any, + context: string, + source: any, + path: string + ) => boolean } export interface Bus {