From 9ad9ce79b44814fa4c69c989d10f4e8f2649dafa Mon Sep 17 00:00:00 2001 From: John Conley <8932043+jfrconley@users.noreply.github.com> Date: Thu, 21 Mar 2024 14:57:02 -0700 Subject: [PATCH] make implement route less strict --- .changeset/tender-fans-try.md | 5 + .../src/runtime/openapi/openapi-router.mts | 7 +- packages/test/src/openapi-spec.ts | 653 +++++++----------- packages/test/src/openapi.ts | 104 +-- 4 files changed, 299 insertions(+), 470 deletions(-) create mode 100644 .changeset/tender-fans-try.md diff --git a/.changeset/tender-fans-try.md b/.changeset/tender-fans-try.md new file mode 100644 index 0000000..ccec562 --- /dev/null +++ b/.changeset/tender-fans-try.md @@ -0,0 +1,5 @@ +--- +"@nornir/rest": patch +--- + +Update handler type of implement route to be less persnickety diff --git a/packages/rest/src/runtime/openapi/openapi-router.mts b/packages/rest/src/runtime/openapi/openapi-router.mts index 14c712f..9be2635 100644 --- a/packages/rest/src/runtime/openapi/openapi-router.mts +++ b/packages/rest/src/runtime/openapi/openapi-router.mts @@ -24,6 +24,7 @@ type GenericRouteBuilder = (chain: Nornir) => Nornir @@ -181,8 +182,9 @@ export class OpenAPIRouter< const Method extends UnionIntersection[Path]>, OpenAPIHttpMethods>, Operation extends OpenAPIV3_1.OperationObject = OperationFromDocument, InputType = RequestTypeFromOperation, - ResponseType = ResponseTypeFromOperation - >(path: Path, method: Method, handler: (chain: Nornir>) => Nornir, NoInfer>) { + ResponseType = ResponseTypeFromOperation, + Handler extends (chain: Nornir>) => Nornir, NoInfer> = (chain: Nornir>) => Nornir, NoInfer> + >(path: Path, method: Method, handler: NoInfer) { const route = { path: path as string, method: method.toUpperCase() as HttpMethod, @@ -284,4 +286,3 @@ export class OpenAPIRouter< } } } - diff --git a/packages/test/src/openapi-spec.ts b/packages/test/src/openapi-spec.ts index fa06283..9c4274a 100644 --- a/packages/test/src/openapi-spec.ts +++ b/packages/test/src/openapi-spec.ts @@ -1,247 +1,165 @@ import { OpenAPIV3_1 } from "@nornir/rest"; -const Spec = { +export default { "openapi": "3.1.0", "info": { - "title": "Nornir API", - "version": "1.0.0", + "title": "Message routing API (beta)", + "version": "0.0.2", + "description": + "## Using the Message routing API\n\n ### Managing webhook destinations\n- **Creating destinations:** Use `POST /destination` to register a new webhook destination. See [Create a new destination](#operation/createDestination).\n- **Listing destinations:** Use `GET /destination` to view all your webhook destinations. See [Get all destinations](#operation/getAllDestinations).\n- **Updating destinations:** Use `PATCH /destination/:id` to modify a destination. See [Update an existing destination](#operation/updateDestination). \n- **Deleting destinations:** Use `DELETE /destination/:id` to remove a specific webhook destination. See [Delete a destination](#operation/deleteDestination).\n\n### Receiving messages\n**Triggering message forwarding**\n- Send a device message from a device to initiate message forwarding.\n- Await messages at your registered webhook destination shortly after sending the device message.\n- Messages arrive in batches to your configured webhook destination.\n- Messages from MQTT topics prefixed with `$ENV/$TEAM_ID/m` are forwarded, with the exception of messages sent to `$ENV/$TEAM_ID/m/d/$DEVICE_ID/d2c/bin`.\n\n### Security and SSL verification\n- **Verifying requests:** Use the `x-nrfcloud-signature` header to verify incoming requests. This signature is an HMAC hex digest of the request payload, hashed using your provided secret. Employ constant-time string comparisons to mitigate timing attacks.\n- **SSL verification:** SSL verification is standard for payload delivery unless you disable it using the `verifySsl` property in your webhook configuration.\n\n### Handling errors\n- Non-2xx responses from your destination webhook are logged as errors. Access the `errors` property of the destination to review the five most recent errors within the past 30 days.\n\n### Access control\n- Full destination management is limited to Admin and Owner roles. Destination viewing is allowed for Editor and Viewer roles.\n\n## Webhook server example\nBelow is an example of a simple Node.js server that receives webhook requests, verifies the `x-nrfcloud-signature`\nheader, and responds with a status code of 200 along with the `x-nrfcloud-team-id` header. This example uses\nExpress, a popular web framework for Node.js, and the crypto module for signature verification. \n\n```javascript\nconst express = require('express');\nconst bodyParser = require('body-parser');\nconst crypto = require('crypto');\n\nconst app = express();\nconst port = 3000;\n\n// Replace this with your actual secret key\nconst secretKey = 'yourSecretKey';\n\napp.use(bodyParser.json());\n\n// Verify nRF Cloud signature\nconst verifySignature = (req) => {\n const signature = req.headers['x-nrfcloud-signature'];\n const body = JSON.stringify(req.body);\n\n // Create HMAC hex digest\n const hmac = crypto.createHmac('sha256', secretKey);\n hmac.update(body, 'utf8');\n const digest = hmac.digest('hex');\n\n return digest === signature;\n};\n\napp.post('/webhook', (req, res) => {\n if (verifySignature(req)) {\n // Your logic here - process the request\n \n // Respond with 200 OK and x-nrfcloud-team-id header\n res.set('x-nrfcloud-team-id', 'yourTeamId');\n res.status(200).send('Webhook received successfully.');\n } else {\n res.status(401).send('Invalid signature.');\n }\n});\n\napp.listen(port, () => {\n console.log(`Webhook receiver running on port ${port}`);\n});\n```\n", }, + "servers": [ + { + "url": "https://message-routing.{stage}.nrfcloud.com", + "description": "Development server", + }, + ], "paths": { - "/docs": { + "/destination": { "get": { + "security": [ + { + "ApiKey": [], + }, + ], "responses": { "200": { "description": "", - "headers": { - "content-type": { - "required": true, - "deprecated": false, - "schema": { - "type": "string", - "const": "text/html", - }, - }, - }, "content": { - "text/html": { + "application/json": { "schema": { - "type": "string", + "type": "array", + "items": { + "allOf": [ + { + "$ref": "#/components/schemas/DestinationProperties_ReadableConfiguration", + }, + ], + }, }, }, }, }, }, - "parameters": [], }, - }, - "/openapi.json": { - "get": { - "responses": { - "200": { - "description": "", - "headers": { - "content-type": { - "required": true, - "deprecated": false, - "schema": { - "type": "string", - "const": "application/json", - }, - }, - }, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": {}, - }, + "post": { + "security": [ + { + "ApiKey": [], + }, + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/DestinationCreationJson", + }, + ], }, }, }, }, - "parameters": [], - }, - }, - "/root/basepath/route/{cool}": { - "get": { - "description": "Cool get route", "responses": { - "200": { - "description": "This is a comment", - "headers": { - "content-type": { - "required": true, - "deprecated": false, - "schema": { - "type": "string", - "const": "application/json", - }, - }, - }, + "201": { + "description": "", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "bleep": { - "type": "string", - }, - "bloop": { - "type": "number", + "allOf": [ + { + "$ref": "#/components/schemas/DestinationProperties_ReadableConfiguration", }, - }, - "required": [ - "bleep", - "bloop", ], - "additionalProperties": false, }, }, }, }, - "201": { - "description": "This is a comment", - "headers": { - "content-type": { - "required": true, - "deprecated": false, - "schema": { - "type": "string", - "const": "application/json", - }, - }, - }, + }, + }, + }, + "/destination/{destinationId}/test": { + "post": { + "security": [ + { + "ApiKey": [], + }, + ], + "responses": { + "200": { + "description": "", "content": { "application/json": { "schema": { "type": "object", "properties": { - "bleep": { - "type": "string", - }, - "bloop": { - "type": "number", + "destination_response": { + "allOf": [ + { + "$ref": "#/components/schemas/HttpResponse", + }, + ], }, }, "required": [ - "bleep", - "bloop", + "destination_response", ], "additionalProperties": false, }, }, }, }, - "400": { - "description": "This is a comment on RouteGetOutputError", - "headers": { - "content-type": { - "required": true, - "deprecated": false, - "schema": { - "type": "string", - "const": "application/json", - }, - }, - }, - "content": { - "application/json": {}, - }, - }, }, "parameters": [ { - "name": "cool", + "name": "destinationId", "in": "path", "required": true, "deprecated": false, "schema": { - "pattern": "^[a-z]+$", "allOf": [ { - "$ref": "#/components/schemas/TestStringType", + "$ref": "#/components/schemas/Uuid", }, ], }, }, ], }, - "post": { - "deprecated": true, - "tags": [ - "cool", + }, + "/destination/{destinationId}": { + "patch": { + "security": [ + { + "ApiKey": [], + }, ], - "operationId": "coolRoute", - "summary": "Cool Route", - "description": "A simple post route", "responses": { "200": { "description": "", - "headers": {}, + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/DestinationProperties_ReadableConfiguration", + }, + ], + }, + }, + }, }, }, "requestBody": { "required": true, "content": { - "text/csv": { - "schema": { - "description": "This is a CSV body", - "examples": [ - "cool,cool2", - ], - "allOf": [ - { - "$ref": "#/components/schemas/TestStringType", - }, - ], - }, - }, "application/json": { - "example": { - "cool": "stuff", - }, "schema": { - "type": "object", - "properties": { - "cool": { - "type": "string", - "description": "This is a cool property", - "minLength": 5, - }, - }, - "required": [ - "cool", - ], - "additionalProperties": false, - "description": "A cool json input", - "examples": [ - { - "cool": "stuff", - }, - ], - }, - }, - "text/plain": { - "example": { - "cool": "stuff", - }, - "schema": { - "type": "object", - "properties": { - "cool": { - "type": "string", - "description": "This is a cool property", - "minLength": 5, - }, - }, - "required": [ - "cool", - ], - "additionalProperties": false, - "description": "A cool json input", - "examples": [ + "allOf": [ { - "cool": "stuff", + "$ref": "#/components/schemas/DestinationMutationJson", }, ], }, @@ -250,312 +168,255 @@ const Spec = { }, "parameters": [ { - "name": "reallyCool", + "name": "destinationId", "in": "path", "required": true, - "description": "Very cool property that does a thing", - "example": "true", "deprecated": false, "schema": { - "anyOf": [ - { - "deprecated": true, - "allOf": [ - { - "$ref": "#/components/schemas/TestStringType", - }, - ], - }, + "allOf": [ { - "type": "string", - "enum": [ - "true", - "false", - ], - "description": "Very cool property that does a thing", - "examples": [ - "true", - ], - "pattern": "^[a-z]+$", + "$ref": "#/components/schemas/Uuid", }, ], }, }, + ], + }, + "delete": { + "security": [ { - "name": "evenCooler", - "in": "path", - "required": false, - "description": "Even cooler property", - "deprecated": false, - "schema": { - "type": "number", - "description": "Even cooler property", - }, + "ApiKey": [], }, - { - "name": "test", - "in": "query", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "const": "boolean", - }, + ], + "responses": { + "204": { + "description": "", }, + }, + "parameters": [ { - "name": "content-type", - "in": "header", + "name": "destinationId", + "in": "path", "required": true, "deprecated": false, "schema": { - "anyOf": [ - { - "type": "string", - "const": "text/csv", - }, - { - "type": "string", - "const": "application/json", - }, + "allOf": [ { - "type": "string", - "const": "text/plain", + "$ref": "#/components/schemas/Uuid", }, ], }, }, - { - "name": "csv-header", - "in": "header", - "required": false, - "description": "This is a CSV header", - "example": "cool,cool2", - "deprecated": false, - "schema": { - "type": "string", - "description": "This is a CSV header", - "examples": [ - "cool,cool2", - ], - "pattern": "^[a-z]+,[a-z]+$", - "minLength": 5, - }, - }, ], }, }, }, "components": { + "securitySchemes": { + "ApiKey": { + "type": "http", + "scheme": "Bearer", + "description": + "Endpoints that specify Authorizations of \"ApiKey\" require you to send your API key\nin the `Authorization` header, e.g., using cURL: `-H \"Authorization: Bearer\nd8be845e816e45d4a9529a6cfcd459c88e3c22b5\"`. Your API key can be found in\nthe Account section of nRFCloud.com.\n\nFor more information on API keys, see\n[the API Key section](https://docs.nrfcloud.com/APIs/REST/RESTOverview.html#api-key)\nin the nRF Cloud documentation.\n", + }, + }, "schemas": { - "HttpHeadersWithoutContentType": { + "Nominal_HttpConfigurationBase_HttpConfigurationWithoutSecrets": { "type": "object", - "additionalProperties": { - "type": [ - "number", - "string", - ], + "additionalProperties": false, + "properties": { + "type": { + "type": "string", + "const": "http", + }, + "url": { + "type": "string", + }, + "verifySsl": { + "type": "boolean", + }, }, + "required": [ + "type", + "url", + "verifySsl", + ], + }, + "DestinationError": { + "type": "object", "properties": { - "content-type": {}, + "reason": { + "type": "string", + }, + "createdAt": { + "type": "string", + }, }, + "required": [ + "reason", + "createdAt", + ], + "additionalProperties": false, }, - "TestStringType": { - "description": "Amazing string", - "pattern": "^[a-z]+$", - "minLength": 5, - "allOf": [ - { - "$ref": "#/components/schemas/Nominal", + "DestinationProperties_ReadableConfiguration": { + "type": "object", + "properties": { + "id": { + "type": "string", }, + "createdAt": { + "type": "string", + }, + "name": { + "type": "string", + }, + "config": { + "$ref": "#/components/schemas/Nominal_HttpConfigurationBase_HttpConfigurationWithoutSecrets", + }, + "isEnabled": { + "type": "boolean", + }, + "errors": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DestinationError", + }, + }, + }, + "required": [ + "id", + "createdAt", + "name", + "config", + "isEnabled", + "errors", ], + "additionalProperties": false, }, - "Nominal": { - "type": [ - "string", + "HttpConfiguration": { + "type": "object", + "additionalProperties": false, + "properties": { + "secret": { + "type": "string", + }, + "type": { + "type": "string", + "const": "http", + }, + "url": { + "type": "string", + }, + "verifySsl": { + "type": "boolean", + }, + }, + "required": [ + "type", + "url", + "verifySsl", ], - "description": - "Constructs a nominal type of type `T`. Useful to prevent any value of type `T` from being used or modified in places it shouldn't (think `id`s).", }, - "RouteGetOutputSuccess": { + "DestinationCreationJson": { "type": "object", "properties": { - "statusCode": { + "name": { "type": "string", - "enum": [ - "200", - "201", - ], - "description": "This is a property", }, - "headers": { - "type": "object", - "properties": { - "content-type": { - "type": "string", - "const": "application/json", - }, - }, - "required": [ - "content-type", - ], - "additionalProperties": false, + "config": { + "$ref": "#/components/schemas/HttpConfiguration", }, - "body": { - "type": "object", - "properties": { - "bleep": { - "type": "string", - }, - "bloop": { - "type": "number", - }, - }, - "required": [ - "bleep", - "bloop", - ], - "additionalProperties": false, + "isEnabled": { + "type": "boolean", }, }, "required": [ - "body", - "headers", - "statusCode", + "name", + "config", + "isEnabled", ], "additionalProperties": false, - "description": "This is a comment", }, - "RouteGetOutputError": { + "Uuid": { + "description": "Universally unique identifier", + "examples": [ + "a5592ec1-18ae-4d9d-bc44-1d9bd927bbe9", + ], + "format": "uuid", + "anyOf": [ + { + "$ref": "#/components/schemas/Nominal_HttpConfigurationBase_HttpConfigurationWithoutSecrets", + }, + ], + }, + "HttpResponse": { "type": "object", "properties": { - "statusCode": { + "status": { + "type": "number", + }, + "body": { "type": "string", - "const": "400", }, "headers": { "type": "object", - "properties": { - "content-type": { - "type": "string", - "const": "application/json", - }, + "additionalProperties": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string", + }, + }, + { + "type": "string", + }, + { + "not": {}, + }, + ], }, - "required": [ - "content-type", - ], - "additionalProperties": false, }, - "body": {}, }, "required": [ + "status", + "body", "headers", - "statusCode", ], "additionalProperties": false, - "description": "This is a comment on RouteGetOutputError", }, - "RoutePostInputJSONAlias": { + "DestinationMutationJson": { "type": "object", "properties": { - "headers": { - "anyOf": [ - { - "type": "object", - "properties": { - "content-type": { - "type": "string", - "const": "application/json", - }, - }, - "required": [ - "content-type", - ], - "additionalProperties": false, - }, - { - "type": "object", - "properties": { - "content-type": { - "type": "string", - "const": "text/plain", - }, - }, - "required": [ - "content-type", - ], - "additionalProperties": false, - }, - ], + "name": { + "type": "string", }, - "query": { + "isEnabled": { + "type": "boolean", + }, + "config": { "type": "object", "properties": { - "test": { + "type": { "type": "string", - "const": "boolean", + "const": "http", }, - }, - "required": [ - "test", - ], - "additionalProperties": false, - }, - "body": { - "type": "object", - "properties": { - "cool": { + "url": { "type": "string", - "description": "This is a cool property", - "minLength": 5, }, - }, - "required": [ - "cool", - ], - "additionalProperties": false, - "description": "A cool json input", - "examples": [ - { - "cool": "stuff", + "verifySsl": { + "type": "boolean", }, - ], - }, - "pathParams": { - "type": "object", - "properties": { - "reallyCool": { + "secret": { "type": "string", - "enum": [ - "true", - "false", - ], - "description": "Very cool property that does a thing", - "examples": [ - "true", - ], - "pattern": "^[a-z]+$", - }, - "evenCooler": { - "type": "number", - "description": "Even cooler property", }, }, - "required": [ - "reallyCool", - ], "additionalProperties": false, }, }, - "required": [ - "body", - "headers", - "pathParams", - "query", - ], "additionalProperties": false, }, }, "parameters": {}, }, } as const satisfies OpenAPIV3_1.Document; -export default Spec; diff --git a/packages/test/src/openapi.ts b/packages/test/src/openapi.ts index f22bd62..00e344e 100644 --- a/packages/test/src/openapi.ts +++ b/packages/test/src/openapi.ts @@ -1,84 +1,46 @@ import nornir from "@nornir/core"; import { ApiGatewayProxyV2, openAPIChain, OpenAPIRouter, OpenAPIV3_1, startLocalServer } from "@nornir/rest"; import type { APIGatewayProxyEventV2, APIGatewayProxyHandlerV2 } from "aws-lambda"; +import Spec from "./openapi-spec.js"; -const Spec = { - info: { - title: "Test API", - version: "1.0.0", - }, - openapi: "3.1.0", - components: { - schemas: { - "cool": { - type: "object", - properties: { - cool: { - type: "string", - }, - }, - }, - "csv": { - type: "string", - pattern: "^[a-zA-Z0-9,]+$", - }, - }, - }, - paths: { - "/cool/test": { - post: { - requestBody: { - required: true, - content: { - "application/json": { - schema: { - $ref: "#/components/schemas/cool", - }, - }, - "text/csv": { - schema: { - $ref: "#/components/schemas/csv", - }, - }, - }, - }, - responses: { - "200": { - description: "cool", - }, - "400": { - description: "bad", - headers: { - "x-foo": { - schema: { - type: "string", - }, - required: true, - }, - }, - }, - }, - }, - }, - }, -} as const satisfies OpenAPIV3_1.Document; +export const router: OpenAPIRouter = OpenAPIRouter.fromSpec(Spec); -const router = OpenAPIRouter.fromSpec(Spec); +// router.implementRoute("/cool/test", "post", chain => +// chain.use(req => { +// if (req.contentType === "text/csv") { +// console.log(req.body.toUpperCase()); +// } else if (req.contentType === "application/json") { +// console.log(req.body.cool); +// } +// return { +// contentType: "application/json", +// statusCode: "400", +// headers: { +// "x-foo": "bar", +// }, +// } as const; +// })); -router.implementRoute("/cool/test", "post", chain => +router.implementRoute("/destination", "post", chain => chain.use(req => { - if (req.contentType === "text/csv") { - console.log(req.body.toUpperCase()); - } else if (req.contentType === "application/json") { - console.log(req.body.cool); - } return { contentType: "application/json", - statusCode: "400", - headers: { - "x-foo": "bar", + statusCode: "201", + headers: {}, + body: { + errors: [], + createdAt: new Date().toISOString(), + isEnabled: true, + name: "test", + id: "123", + type: "destination", + config: { + type: "http", + verifySsl: true, + url: "https://example.com", + }, }, - } as const; + }; })); export const handler: APIGatewayProxyHandlerV2 = nornir()