diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index ba8fdece..7f19ca4a 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -53,9 +53,6 @@ jobs: - name: 📥 Install deps run: npm install - - name: 🧾 Generate OpenAPI spec - run: npm run build:docs - - name: 🔎 Type check run: npm run typecheck --if-present diff --git a/.gitignore b/.gitignore index b80cdd22..2d38df51 100644 --- a/.gitignore +++ b/.gitignore @@ -6,7 +6,6 @@ node_modules /build /public/build -/public/openapi.json .env .cache @@ -26,4 +25,3 @@ measurements.csv /minio-data /rustfs-data -/public/openapi.json diff --git a/app/lib/env.server.ts b/app/lib/env.server.ts index 6d80ad9f..6999b77b 100644 --- a/app/lib/env.server.ts +++ b/app/lib/env.server.ts @@ -39,6 +39,7 @@ export function init() { export function getEnv() { return { NOMINATIM_SEARCH_API: process.env.NOMINATIM_SEARCH_API, + OSEM_GITHUB_URL: process.env.OSEM_API_URL, MODE: process.env.NODE_ENV, DIRECTUS_URL: process.env.DIRECTUS_URL, MYBADGES_API_URL: process.env.MYBADGES_API_URL, diff --git a/app/lib/integration.openapi.ts b/app/lib/integration.openapi.ts deleted file mode 100644 index e58140bc..00000000 --- a/app/lib/integration.openapi.ts +++ /dev/null @@ -1,460 +0,0 @@ -import swaggerJsdoc from 'swagger-jsdoc' - -const options: swaggerJsdoc.Options = { - definition: { - openapi: '3.0.0', - info: { - title: 'OpenSenseMap Integration API', - version: '1.0.0', - description: ` -# Building OpenSenseMap Integrations - -OpenSenseMap uses a plugin architecture for integrations. Any service implementing this specification can connect devices from any platform or protocol to OpenSenseMap. - -## Architecture - -\`\`\` -OpenSenseMap (Main App) - ↓ Registers & calls via HTTP -Your Integration Service - ↓ Receives data from -Your Protocol (MQTT/LoRa/etc.) -\`\`\` - -## Required Endpoints - -Your integration service MUST implement these endpoints: - -1. **GET /integrations/:deviceId** - Get integration config -2. **PUT /integrations/:deviceId** - Create/update integration config -3. **DELETE /integrations/:deviceId** - Delete integration config -4. **GET /integrations/schema/{name}** - Return JSON Schema for config form -5. **GET /health** - Health check - -## Authentication - -All endpoints (except /health) require \`x-service-key\` header. - -## Forwarding Measurements - -After processing data, POST measurements to OpenSenseMap: - -**Endpoint:** \`POST /api/boxes/:deviceId/:sensorId\` - -**Headers:** -- \`Content-Type: application/json\` -- \`x-service-key\`: Your service key (provided by OpenSenseMap) - -**Body:** -\`\`\`json -{ - "value": 23.5, - "createdAt": "2026-02-06T10:00:00Z", - "location": { - "lng": 7.628, - "lat": 51.963, - "height": 100 - } -} -\`\`\` - -## Reference Implementations - -- [MQTT Integration](https://github.com/opensensemap/mqtt-integration) -- [TTN Integration](https://github.com/opensensemap/ttn-integration) - -## Registration - -To register your integration, contact OpenSenseMap admins with: -- Service name and description -- Service URL and authentication key -- Icon (Lucide icon name) -- JSON Schema endpoint path - `, - }, - servers: [ - { - url: 'https://your-integration-service.com', - description: 'Your integration microservice', - }, - ], - components: { - securitySchemes: { - ServiceKey: { - type: 'apiKey', - in: 'header', - name: 'x-service-key', - description: 'Service authentication key configured in OpenSenseMap', - }, - }, - parameters: { - DeviceId: { - name: 'deviceId', - in: 'path', - required: true, - schema: { - type: 'string', - }, - description: 'OpenSenseMap device ID', - example: 'cm65qexample123', - }, - }, - schemas: { - IntegrationConfig: { - type: 'object', - description: - 'Integration configuration (schema varies by integration type)', - additionalProperties: true, - example: { - id: 'intg_123', - deviceId: 'cm65qexample123', - enabled: true, - url: 'mqtt://broker.example.com', - topic: 'sensors/data', - messageFormat: 'json', - }, - }, - JsonSchema: { - type: 'object', - description: 'JSON Schema (draft-07) for dynamic form generation', - properties: { - schema: { - type: 'object', - description: 'JSON Schema definition', - }, - uiSchema: { - type: 'object', - description: 'React JSON Schema Form UI Schema', - }, - }, - required: ['schema'], - }, - Error: { - type: 'object', - properties: { - error: { - type: 'string', - }, - details: { - type: 'array', - items: { - type: 'string', - }, - }, - }, - }, - }, - responses: { - NotFound: { - description: 'Resource not found', - content: { - 'application/json': { - schema: { - $ref: '#/components/schemas/Error', - }, - example: { - error: 'Integration not found', - }, - }, - }, - }, - ValidationError: { - description: 'Validation failed', - content: { - 'application/json': { - schema: { - $ref: '#/components/schemas/Error', - }, - example: { - error: 'Validation failed', - details: [ - 'url is required and must be a string', - 'topic is required and must be a string', - ], - }, - }, - }, - }, - Unauthorized: { - description: 'Unauthorized - invalid or missing service key', - content: { - 'application/json': { - schema: { - $ref: '#/components/schemas/Error', - }, - example: { - error: 'Unauthorized', - }, - }, - }, - }, - InternalError: { - description: 'Internal server error', - content: { - 'application/json': { - schema: { - $ref: '#/components/schemas/Error', - }, - example: { - error: 'Internal server error', - }, - }, - }, - }, - }, - }, - security: [ - { - ServiceKey: [], - }, - ], - tags: [ - { - name: 'Integration Management', - description: 'CRUD operations for integration configurations', - }, - { - name: 'Schema', - description: 'JSON Schema for dynamic form generation', - }, - { - name: 'Health', - description: 'Service health check', - }, - ], - paths: { - '/integrations/{deviceId}': { - get: { - summary: 'Get integration configuration for a device', - operationId: 'getIntegration', - tags: ['Integration Management'], - parameters: [ - { - $ref: '#/components/parameters/DeviceId', - }, - ], - responses: { - '200': { - description: 'Integration configuration', - content: { - 'application/json': { - schema: { - $ref: '#/components/schemas/IntegrationConfig', - }, - }, - }, - }, - '404': { - $ref: '#/components/responses/NotFound', - }, - '401': { - $ref: '#/components/responses/Unauthorized', - }, - '500': { - $ref: '#/components/responses/InternalError', - }, - }, - }, - put: { - summary: 'Create or update integration configuration', - operationId: 'createOrUpdateIntegration', - tags: ['Integration Management'], - parameters: [ - { - $ref: '#/components/parameters/DeviceId', - }, - ], - requestBody: { - required: true, - content: { - 'application/json': { - schema: { - type: 'object', - description: - 'Configuration specific to your integration type', - additionalProperties: true, - }, - examples: { - mqtt: { - summary: 'MQTT Integration', - value: { - url: 'mqtt://broker.example.com:1883', - topic: 'sensors/temperature', - messageFormat: 'json', - connectionOptions: { - username: 'user', - password: 'pass', - }, - }, - }, - ttn: { - summary: 'TTN Integration', - value: { - devId: 'my-device', - appId: 'my-app', - profile: 'cayenne-lpp', - }, - }, - }, - }, - }, - }, - responses: { - '200': { - description: 'Integration updated', - content: { - 'application/json': { - schema: { - $ref: '#/components/schemas/IntegrationConfig', - }, - }, - }, - }, - '201': { - description: 'Integration created', - content: { - 'application/json': { - schema: { - $ref: '#/components/schemas/IntegrationConfig', - }, - }, - }, - }, - '400': { - $ref: '#/components/responses/ValidationError', - }, - '401': { - $ref: '#/components/responses/Unauthorized', - }, - '500': { - $ref: '#/components/responses/InternalError', - }, - }, - }, - delete: { - summary: 'Delete integration configuration', - operationId: 'deleteIntegration', - tags: ['Integration Management'], - parameters: [ - { - $ref: '#/components/parameters/DeviceId', - }, - ], - responses: { - '204': { - description: 'Integration deleted successfully', - }, - '404': { - $ref: '#/components/responses/NotFound', - }, - '401': { - $ref: '#/components/responses/Unauthorized', - }, - '500': { - $ref: '#/components/responses/InternalError', - }, - }, - }, - }, - '/integrations/schema/{integrationName}': { - get: { - summary: 'Get JSON Schema for integration configuration form', - operationId: 'getIntegrationSchema', - tags: ['Schema'], - parameters: [ - { - name: 'integrationName', - in: 'path', - required: true, - schema: { - type: 'string', - }, - example: 'mqtt', - }, - ], - responses: { - '200': { - description: 'JSON Schema for dynamic form generation', - content: { - 'application/json': { - schema: { - $ref: '#/components/schemas/JsonSchema', - }, - examples: { - mqtt: { - summary: 'MQTT Schema Example', - value: { - schema: { - type: 'object', - required: ['url', 'topic', 'messageFormat'], - properties: { - url: { - type: 'string', - title: 'Broker URL', - pattern: '^(mqtt|mqtts|ws|wss)://.+', - }, - topic: { - type: 'string', - title: 'Topic', - }, - messageFormat: { - type: 'string', - title: 'Message Format', - enum: ['json', 'csv'], - }, - }, - }, - uiSchema: { - 'ui:order': ['url', 'topic', 'messageFormat'], - }, - }, - }, - }, - }, - }, - }, - '401': { - $ref: '#/components/responses/Unauthorized', - }, - '500': { - $ref: '#/components/responses/InternalError', - }, - }, - }, - }, - '/health': { - get: { - summary: 'Health check endpoint', - operationId: 'healthCheck', - tags: ['Health'], - security: [], - responses: { - '200': { - description: 'Service is healthy', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - status: { - type: 'string', - example: 'healthy', - }, - timestamp: { - type: 'string', - format: 'date-time', - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - apis: [], -} - -export const integrationOpenapiSpecification = () => swaggerJsdoc(options) diff --git a/app/lib/openapi.combined.ts b/app/lib/openapi.combined.ts deleted file mode 100644 index ba3da16f..00000000 --- a/app/lib/openapi.combined.ts +++ /dev/null @@ -1,172 +0,0 @@ -import { type OpenAPIV3 } from 'openapi-types' -import { integrationOpenapiSpecification } from './integration.openapi' -import { openapiSpecification } from './openapi' - -type OpenAPIDocumentWithTagGroups = OpenAPIV3.Document & { - 'x-tagGroups'?: Array<{ name: string; tags: string[] }> -} - -const tagAnchor = (tag: string) => `#/${encodeURIComponent(tag)}` - -function collectOperationTagNames(paths?: OpenAPIV3.PathsObject): string[] { - if (!paths) return [] - - const set = new Set() - - for (const pathItem of Object.values(paths)) { - const item = (pathItem ?? {}) as OpenAPIV3.PathItemObject - - const methods = [ - 'get', - 'put', - 'post', - 'delete', - 'patch', - 'options', - 'trace', - ] as const - - for (const m of methods) { - const op = item[m] - if (!op?.tags) continue - for (const t of op.tags) set.add(t) - } - } - - return [...set] -} - -function prefixOperationTags( - paths: OpenAPIV3.PathsObject | undefined, - prefix: string, -): OpenAPIV3.PathsObject { - const result: OpenAPIV3.PathsObject = {} - if (!paths) return result - - for (const [path, pathItem] of Object.entries(paths)) { - const item = (pathItem ?? {}) as OpenAPIV3.PathItemObject - const out: OpenAPIV3.PathItemObject = { ...item } - - const methods = [ - 'get', - 'put', - 'post', - 'delete', - 'patch', - 'options', - ] as const - - for (const m of methods) { - const op = item[m] - if (!op) continue - - out[m] = { - ...op, - tags: op.tags?.map((t) => `${prefix} · ${t}`), - } - } - - result[path] = out - } - - return result -} - -function mergeComponents( - a?: OpenAPIV3.ComponentsObject, - b?: OpenAPIV3.ComponentsObject, -): OpenAPIV3.ComponentsObject | undefined { - if (!a && !b) return undefined - return { - ...(a ?? {}), - ...(b ?? {}), - schemas: { ...(a?.schemas ?? {}), ...(b?.schemas ?? {}) }, - parameters: { ...(a?.parameters ?? {}), ...(b?.parameters ?? {}) }, - responses: { ...(a?.responses ?? {}), ...(b?.responses ?? {}) }, - securitySchemes: { - ...(a?.securitySchemes ?? {}), - ...(b?.securitySchemes ?? {}), - }, - } -} - -export const combinedOpenapiSpecification = - (): OpenAPIDocumentWithTagGroups => { - const main = openapiSpecification() as OpenAPIV3.Document - const integration = integrationOpenapiSpecification() as OpenAPIV3.Document - - const mainTagNames = - (main.tags?.map((t) => t.name) ?? []).length > 0 - ? main.tags!.map((t) => t.name) - : collectOperationTagNames(main.paths) - - const integrationTagNames = - (integration.tags?.map((t) => t.name) ?? []).length > 0 - ? integration.tags!.map((t) => t.name) - : collectOperationTagNames(integration.paths) - - const publicTags: OpenAPIV3.TagObject[] = - (main.tags?.length ?? 0) > 0 - ? main.tags!.map((t) => ({ ...t, name: `Public · ${t.name}` })) - : mainTagNames.sort().map((name) => ({ name: `Public · ${name}` })) - - const integrationTags: OpenAPIV3.TagObject[] = - (integration.tags?.length ?? 0) > 0 - ? integration.tags!.map((t) => ({ - ...t, - name: `Integration · ${t.name}`, - })) - : integrationTagNames - .sort() - .map((name) => ({ name: `Integration · ${name}` })) - - const allTags = [...publicTags, ...integrationTags] - - const publicJump = publicTags[0]?.name - const integrationJump = integrationTags[0]?.name - - const topLinks = [ - publicJump ? `- [Public API](${tagAnchor(publicJump)})` : `- Public API`, - integrationJump - ? `- [Integration API](${tagAnchor(integrationJump)})` - : `- Integration API`, - ].join('\n') - - return { - ...main, - - info: { - ...main.info, - title: 'OpenSenseMap API', - description: `# OpenSenseMap API Documentation - -Use the links below or the sidebar to navigate. - -${topLinks} -`, - }, - - // Public first => Integration appears below in Swagger UI (assuming no tagsSorter="alpha") - tags: allTags, - - paths: { - ...prefixOperationTags(main.paths, 'Public'), - ...prefixOperationTags(integration.paths, 'Integration'), - }, - - components: mergeComponents(main.components, integration.components), - - security: [...(main.security ?? []), ...(integration.security ?? [])], - - 'x-tagGroups': [ - { - name: 'Public API', - tags: publicTags.map((t) => t.name), - }, - { - name: 'Integration API', - tags: integrationTags.map((t) => t.name), - }, - ], - } - } diff --git a/app/lib/openapi.ts b/app/lib/openapi.ts index 0ce373b1..cb96b782 100644 --- a/app/lib/openapi.ts +++ b/app/lib/openapi.ts @@ -1,124 +1,521 @@ -import swaggerJsdoc from 'swagger-jsdoc' +import { + ZodOpenApiObject, + ZodOpenApiPathItemObject, + ZodOpenApiPathsObject, +} from 'zod-openapi' const DEV_SERVER = { url: 'http://localhost:3000', description: 'Development server', } -const options: swaggerJsdoc.Options = { - definition: { - openapi: '3.0.0', +const convertFilePathToApiPath = (filePath: string) => { + // Extract filename and remove extension + // /app/routes/api.users.$id.tsx -> api.users.$id + const fileName = + filePath + .split('/') + .pop() + ?.replace(/\.(tsx|ts|jsx|js)$/, '') || '' + + // Handle root routes + if (fileName === 'root' || fileName === 'home' || fileName === 'index') { + return '/' + } + + // Convert dots to slashes (path separator convention) + // api.users.$id -> api/users/$id + let path = fileName.replace(/\./g, '/') + + // Convert $param to {param} for OpenAPI + // api/users/$id -> api/users/{id} + path = path.replace(/\$(\w+)/g, '{$1}') + + // Add leading slash + return `/${path}` +} + +export const generateOpenApiPathsSpec = (): ZodOpenApiPathsObject => { + const routes = import.meta.glob<{ + openapi?: ZodOpenApiPathItemObject + [key: string]: any + }>('/app/routes/api.*.ts', { eager: true }) + + const paths: ZodOpenApiPathsObject = {} + + for (const [filePath, module] of Object.entries(routes)) { + if (!module.openapi) continue + + const apiPath = convertFilePathToApiPath(filePath) + + // Merge methods into path + paths[apiPath] = { + ...paths[apiPath], + ...module.openapi, + } + } + + return paths +} + +export const generateOpenApiServerSpec = (): { + url: string + description: string +}[] => { + return [ + ...(process.env.NODE_ENV !== 'production' ? [DEV_SERVER] : []), + { + url: process.env.OSEM_API_URL, + description: 'Production server', + }, + ] +} + +export const generateIntegrationApiSpec = (): ZodOpenApiObject => { + return { + openapi: '3.1.0', info: { - title: 'openSenseMap API', + title: 'API Schema for Integrations', version: '1.0.0', - description: `## Documentation of the routes and methods to manage users, stations (also called boxes or senseBoxes), and measurements in the openSenseMap API. You can find the API running at [https://opensensemap.org/api/](https://opensensemap.org/api/). -# Timestamps + description: ` +# Building OpenSenseMap Integrations -## Please note that the API handles every timestamp in Coordinated universal time (UTC) time zone. Timestamps in parameters should be in RFC 3339 notation. +OpenSenseMap uses a plugin architecture for integrations. Any service implementing this specification can connect devices from any platform or protocol to OpenSenseMap. -**Timestamp without Milliseconds:** +## Architecture \`\`\` -2018-02-01T23:18:02Z +OpenSenseMap (Main App) + ↓ Registers & calls via HTTP +Your Integration Service + ↓ Receives data from +Your Protocol (MQTT/LoRa/etc.) \`\`\` -**Timestamp with Milliseconds:** +## Required Endpoints -\`\`\` -2018-02-01T23:18:02.412Z -\`\`\` +Your integration service MUST implement these endpoints: -# IDs +1. **GET /integrations/:deviceId** - Get integration config +2. **PUT /integrations/:deviceId** - Create/update integration config +3. **DELETE /integrations/:deviceId** - Delete integration config +4. **GET /integrations/schema/{name}** - Return JSON Schema for config form +5. **GET /health** - Health check -## All stations and sensors of stations receive a unique public identifier. These identifiers are exactly 24 character long and only contain digits and characters a to f. +## Authentication -**Example:** +All endpoints (except /health) require \`x-service-key\` header. -\`\`\` -5a8d1c25bc2d41001927a265 -\`\`\` +## Forwarding Measurements -# Parameters +After processing data, POST measurements to OpenSenseMap: -## Only if noted otherwise, all requests assume the payload encoded as JSON with \`Content-type: application/json\` header. Parameters prepended with a colon (\`:\`) are parameters which should be specified through the URL. +**Endpoint:** \`POST /api/boxes/:deviceId/:sensorId\` -# Source code and Licenses +**Headers:** +- \`Content-Type: application/json\` +- \`x-service-key\`: Your service key (provided by OpenSenseMap) -## You can find the whole source code of the API at GitHub in the [sensebox/openSenseMap-API](https://github.com/sensebox/openSenseMap-API) repository. You can obtain the code under the MIT License. +**Body:** +\`\`\`json +{ + "value": 23.5, + "createdAt": "2026-02-06T10:00:00Z", + "location": { + "lng": 7.628, + "lat": 51.963, + "height": 100 + } +} +\`\`\` -## The data obtainable through the openSenseMap API at [https://opensensemap.org/api/](https://opensensemap.org/api/) is licensed under the [Public Domain Dedication and License 1.0](https://opendatacommons.org/licenses/pddl/summary/). +## Reference Implementations -## If there is something unclear or there is a mistake in this documentation please open an [issue](https://github.com/openSenseMap/frontend/issues/new) in the GitHub repository.`, +- [MQTT Integration](https://github.com/opensensemap/mqtt-integration) +- [TTN Integration](https://github.com/opensensemap/ttn-integration) + +## Registration + +To register your integration, contact OpenSenseMap admins with: +- Service name and description +- Service URL and authentication key +- Icon (Lucide icon name) +- JSON Schema endpoint path + `, }, servers: [ - ...(process.env.NODE_ENV !== 'production' ? [DEV_SERVER] : []), { - url: process.env.OSEM_API_URL || 'https://opensensemap.org/api', // Uses environment variable or defaults to production URL - description: 'Production server', + url: 'https://your-integration-service.com', + description: 'Your integration microservice', }, ], components: { - schemas: { - SenseBoxId: { - type: 'string', - pattern: '^[a-f0-9]{24}$', - description: - 'Unique identifier for stations and sensors (24 characters, digits and a-f only)', - example: '5a8d1c25bc2d41001927a265', - }, - Timestamp: { - type: 'string', - format: 'date-time', - description: 'RFC 3339 timestamp in UTC timezone', - examples: ['2018-02-01T23:18:02Z', '2018-02-01T23:18:02.412Z'], + securitySchemes: { + ServiceKey: { + type: 'apiKey', + in: 'header', + name: 'x-service-key', + description: 'Service authentication key configured in OpenSenseMap', }, }, parameters: { - SenseBoxIdParam: { - name: 'id', + DeviceId: { + name: 'deviceId', in: 'path', required: true, schema: { - $ref: '#/components/schemas/SenseBoxId', + type: 'string', }, - description: 'SenseBox ID parameter', + description: 'OpenSenseMap device ID', + example: 'cm65qexample123', }, - TimestampParam: { - name: 'timestamp', - in: 'query', - schema: { - $ref: '#/components/schemas/Timestamp', + }, + schemas: { + IntegrationConfig: { + type: 'object', + description: + 'Integration configuration (schema varies by integration type)', + additionalProperties: true, + example: { + id: 'intg_123', + deviceId: 'cm65qexample123', + enabled: true, + url: 'mqtt://broker.example.com', + topic: 'sensors/data', + messageFormat: 'json', + }, + }, + JsonSchema: { + type: 'object', + description: 'JSON Schema (draft-07) for dynamic form generation', + properties: { + schema: { + type: 'object', + description: 'JSON Schema definition', + }, + uiSchema: { + type: 'object', + description: 'React JSON Schema Form UI Schema', + }, + }, + required: ['schema'], + }, + Error: { + type: 'object', + properties: { + error: { + type: 'string', + }, + details: { + type: 'array', + items: { + type: 'string', + }, + }, }, - description: 'Timestamp parameter in RFC 3339 format (UTC)', }, }, responses: { - BadRequest: { - description: 'Bad Request - Invalid parameters or payload', + NotFound: { + description: 'Resource not found', content: { 'application/json': { schema: { - type: 'object', - properties: { - error: { - type: 'string', - example: 'Invalid request parameters', - }, - }, + $ref: '#/components/schemas/Error', + }, + example: { + error: 'Integration not found', }, }, }, }, - NotFound: { - description: 'Resource not found', + ValidationError: { + description: 'Validation failed', content: { 'application/json': { schema: { - type: 'object', - properties: { - error: { - type: 'string', - example: 'Resource not found', + $ref: '#/components/schemas/Error', + }, + example: { + error: 'Validation failed', + details: [ + 'url is required and must be a string', + 'topic is required and must be a string', + ], + }, + }, + }, + }, + Unauthorized: { + description: 'Unauthorized - invalid or missing service key', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Error', + }, + example: { + error: 'Unauthorized', + }, + }, + }, + }, + InternalError: { + description: 'Internal server error', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Error', + }, + example: { + error: 'Internal server error', + }, + }, + }, + }, + }, + }, + security: [ + { + ServiceKey: [], + }, + ], + tags: [ + { + name: 'Integration Management', + description: 'CRUD operations for integration configurations', + }, + { + name: 'Schema', + description: 'JSON Schema for dynamic form generation', + }, + { + name: 'Health', + description: 'Service health check', + }, + ], + paths: { + '/integrations/{deviceId}': { + get: { + summary: 'Get integration configuration for a device', + operationId: 'getIntegration', + tags: ['Integration Management'], + parameters: [ + { + $ref: '#/components/parameters/DeviceId', + }, + ], + responses: { + '200': { + description: 'Integration configuration', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/IntegrationConfig', + }, + }, + }, + }, + '404': { + $ref: '#/components/responses/NotFound', + }, + '401': { + $ref: '#/components/responses/Unauthorized', + }, + '500': { + $ref: '#/components/responses/InternalError', + }, + }, + }, + put: { + summary: 'Create or update integration configuration', + operationId: 'createOrUpdateIntegration', + tags: ['Integration Management'], + parameters: [ + { + $ref: '#/components/parameters/DeviceId', + }, + ], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + description: + 'Configuration specific to your integration type', + additionalProperties: true, + }, + examples: { + mqtt: { + summary: 'MQTT Integration', + value: { + url: 'mqtt://broker.example.com:1883', + topic: 'sensors/temperature', + messageFormat: 'json', + connectionOptions: { + username: 'user', + password: 'pass', + }, + }, + }, + ttn: { + summary: 'TTN Integration', + value: { + devId: 'my-device', + appId: 'my-app', + profile: 'cayenne-lpp', + }, + }, + }, + }, + }, + }, + responses: { + '200': { + description: 'Integration updated', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/IntegrationConfig', + }, + }, + }, + }, + '201': { + description: 'Integration created', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/IntegrationConfig', + }, + }, + }, + }, + '400': { + $ref: '#/components/responses/ValidationError', + }, + '401': { + $ref: '#/components/responses/Unauthorized', + }, + '500': { + $ref: '#/components/responses/InternalError', + }, + }, + }, + delete: { + summary: 'Delete integration configuration', + operationId: 'deleteIntegration', + tags: ['Integration Management'], + parameters: [ + { + $ref: '#/components/parameters/DeviceId', + }, + ], + responses: { + '204': { + description: 'Integration deleted successfully', + }, + '404': { + $ref: '#/components/responses/NotFound', + }, + '401': { + $ref: '#/components/responses/Unauthorized', + }, + '500': { + $ref: '#/components/responses/InternalError', + }, + }, + }, + }, + '/integrations/schema/{integrationName}': { + get: { + summary: 'Get JSON Schema for integration configuration form', + operationId: 'getIntegrationSchema', + tags: ['Schema'], + parameters: [ + { + name: 'integrationName', + in: 'path', + required: true, + schema: { + type: 'string', + }, + example: 'mqtt', + }, + ], + responses: { + '200': { + description: 'JSON Schema for dynamic form generation', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/JsonSchema', + }, + examples: { + mqtt: { + summary: 'MQTT Schema Example', + value: { + schema: { + type: 'object', + required: ['url', 'topic', 'messageFormat'], + properties: { + url: { + type: 'string', + title: 'Broker URL', + pattern: '^(mqtt|mqtts|ws|wss)://.+', + }, + topic: { + type: 'string', + title: 'Topic', + }, + messageFormat: { + type: 'string', + title: 'Message Format', + enum: ['json', 'csv'], + }, + }, + }, + uiSchema: { + 'ui:order': ['url', 'topic', 'messageFormat'], + }, + }, + }, + }, + }, + }, + }, + '401': { + $ref: '#/components/responses/Unauthorized', + }, + '500': { + $ref: '#/components/responses/InternalError', + }, + }, + }, + }, + '/health': { + get: { + summary: 'Health check endpoint', + operationId: 'healthCheck', + tags: ['Health'], + security: [], + responses: { + '200': { + description: 'Service is healthy', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + status: { + type: 'string', + example: 'healthy', + }, + timestamp: { + type: 'string', + format: 'date-time', + }, + }, }, }, }, @@ -127,9 +524,5 @@ const options: swaggerJsdoc.Options = { }, }, }, - }, - // Path to the API routes containing JSDoc annotations - apis: ['./app/routes/api.*.ts'], // Adjust path as needed + } } - -export const openapiSpecification = () => swaggerJsdoc(options) diff --git a/app/middleware/content-type-header.server.ts b/app/middleware/content-type-header.server.ts new file mode 100644 index 00000000..5795f4c8 --- /dev/null +++ b/app/middleware/content-type-header.server.ts @@ -0,0 +1,29 @@ +import { MiddlewareFunction } from 'react-router' +import { StandardResponse } from '~/lib/responses' + +/** + * A middleware function responding with HTTP 415 Unsupported Media Type + * to requests that do not contain the Content-Type: application/json header. + */ +export const requestContentTypeJson: MiddlewareFunction = ({ + request, +}) => { + const contentType = request.headers.get('content-type') || '' + if (!contentType.includes('application/json')) + return StandardResponse.unsupportedMediaType( + 'Unsupported content-type. Try application/json', + ) +} + +/** + * A middleware function that sets the Content-Type: application/json + * header with utf-8 charset for all outgoing responses. + */ +export const responseContentTypeJson: MiddlewareFunction = async ( + _, + next, +) => { + const res = await next() + res.headers.set('Content-Type', 'application/json; charset=utf-8') + return res +} diff --git a/app/routes/api.ts b/app/routes/api.ts index 55bce50a..33a0e4ae 100644 --- a/app/routes/api.ts +++ b/app/routes/api.ts @@ -1,3 +1,4 @@ +/// import { type Route } from '../+types/root' import { apiRoutes as routes } from '~/lib/api-routes' import { tosApiMiddleware } from '~/middleware/tos-api.server' diff --git a/app/routes/api.users.sign-in.ts b/app/routes/api.users.sign-in.ts index e56c846e..c2c1ab2c 100644 --- a/app/routes/api.users.sign-in.ts +++ b/app/routes/api.users.sign-in.ts @@ -1,151 +1,142 @@ +import { z } from 'zod' import { type Route } from './+types/api.users.sign-in' -import { parseUserSignInData } from '~/lib/request-parsing' import { StandardResponse } from '~/lib/responses' import { signIn } from '~/services/user-service.server' -/** - * @openapi - * /api/users/sign-in: - * post: - * tags: - * - Authentication - * summary: User sign-in - * description: Authenticates a user with email and password credentials - * operationId: signInUser - * requestBody: - * required: true - * content: - * application/x-www-form-urlencoded: - * schema: - * type: object - * required: - * - email - * - password - * properties: - * email: - * type: string - * format: email - * description: User's email address - * example: user@example.com - * password: - * type: string - * format: password - * description: User's password - * minLength: 8 - * example: mySecurePassword123 - * responses: - * 200: - * description: Successfully authenticated - * content: - * application/json: - * schema: - * type: object - * properties: - * code: - * type: string - * example: Authorized - * message: - * type: string - * example: Successfully signed in - * data: - * type: object - * properties: - * user: - * $ref: '#/components/schemas/User' - * token: - * type: string - * description: JWT access token - * example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... - * refreshToken: - * type: string - * description: JWT refresh token - * example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... - * 403: - * description: Authentication failed - invalid credentials or missing fields - * content: - * application/json: - * schema: - * type: object - * properties: - * code: - * type: string - * example: Forbidden - * message: - * type: string - * enum: - * - You must specify either your email or your username - * - You must specify your password to sign in - * - User and or password not valid! - * 500: - * description: Internal server error - * content: - * text/plain: - * schema: - * type: string - * example: Internal Server Error - * components: - * schemas: - * User: - * type: object - * description: User information object - * properties: - * id: - * type: string - * description: Unique user identifier - * email: - * type: string - * format: email - * description: User's email address - * name: - * type: string - * description: User's display name - * createdAt: - * type: string - * format: date-time - * description: Account creation timestamp - * updatedAt: - * type: string - * format: date-time - * description: Last account update timestamp - */ -export const action = async ({ request }: Route.ActionArgs) => { - try { - // Parse request data - handles both JSON and form data automatically - const data = await parseUserSignInData(request) +import { ZodOpenApiPathItemObject } from 'zod-openapi' +import { + requestContentTypeJson, + responseContentTypeJson, +} from '~/middleware/content-type-header.server' + +const errorMessages = { + email: 'You must specify either your email or your username', + password: 'You must specify your password to sign in', + userAndOrPassword: 'User and or password not valid!', +} + +const PostRequestSchema = z.object({ + email: z.string(errorMessages.email).trim().nonempty().meta({ + description: "User's email address or username", + example: 'user@example.com', + }), + password: z.string(errorMessages.password).nonempty().min(8).meta({ + description: "User's password", + example: 'mySecurePassword123', + }), +}) - const email = data.email.trim() - const password = data.password.trim() +const PostResponseSchema = z.object({ + data: z.object( + { + user: z.object({ + name: z.string(), + ...PostRequestSchema.pick({ email: true }).shape, + role: z.string(), + language: z.string(), + emailIsConfirmed: z.boolean(), + boxes: z.array(z.string()).meta({ + description: 'A list of ids of the users devices', + example: ['60a13611a877b3001b8ffd59', '5bdbe70f55d0ad001a04edc9'], + }), + }), + }, + errorMessages.userAndOrPassword, + ), + token: z.jwt({ alg: 'HS256', error: errorMessages.userAndOrPassword }).meta({ + description: 'valid json web token', + }), + refreshToken: z.string(errorMessages.userAndOrPassword).meta({ + description: 'valid json web token', + }), + code: z.literal('Authorized').default('Authorized'), + message: z + .literal('Successfully signed in') + .default('Successfully signed in'), +}) - if (!email || email.length === 0) - return StandardResponse.forbidden( - 'You must specify either your email or your username', - ) +export const openapi: ZodOpenApiPathItemObject = { + post: { + tags: ['Auth'], + summary: 'Sign in using email or name and password', + requestBody: { + required: true, + content: { + 'application/json': { schema: PostRequestSchema }, + }, + }, + responses: { + 200: { + description: 'Signed in', + content: { + 'application/json': { schema: PostResponseSchema }, + }, + }, + 403: { + description: 'Unauthorized', + content: { + 'application/json': { + schema: z.object({ + code: z.literal('Forbidden'), + message: z.xor([ + z.literal(errorMessages.email), + z.literal(errorMessages.password), + z.literal(errorMessages.userAndOrPassword), + ]), + error: z.xor([ + z.literal(errorMessages.email), + z.literal(errorMessages.password), + z.literal(errorMessages.userAndOrPassword), + ]), + }), + }, + }, + }, + 500: { + description: 'Internal Server Error', + content: { + 'application/json': { + schema: z.object({ + code: z.literal('Internal Server Error'), + message: z.literal( + 'The server was unable to complete your request. Please try again later.', + ), + error: z.literal( + 'The server was unable to complete your request. Please try again later.', + ), + }), + }, + }, + }, + }, + }, +} - if (!password || password.length === 0) { - return StandardResponse.forbidden( - 'You must specify your password to sign in', - ) - } +export const middleware: Route.MiddlewareFunction[] = [ + requestContentTypeJson, + responseContentTypeJson, +] +export const action = async ({ request }: Route.ActionArgs) => { + try { + const requestParsed = await PostRequestSchema.safeParseAsync( + await request.json(), + ) + if (!requestParsed.success) + return StandardResponse.forbidden(requestParsed.error.issues[0].message) + + const { email, password } = requestParsed.data const { user, jwt, refreshToken } = (await signIn(email, password)) || {} - if (user && jwt && refreshToken) - return StandardResponse.ok({ - code: 'Authorized', - message: 'Successfully signed in', - data: { user }, - token: jwt, - refreshToken, - }) - else return StandardResponse.forbidden('User and or password not valid!') - } catch (error) { - // Handle parsing errors - if (error instanceof Error && error.message.includes('Failed to parse')) { - return StandardResponse.forbidden( - `Invalid request format: ${error.message}`, - ) - } + const responseParsed = await PostResponseSchema.safeParseAsync({ + data: { user }, + token: jwt, + refreshToken, + }) + if (!responseParsed.success) + return StandardResponse.forbidden(responseParsed.error.issues[0].message) - // Handle other errors + return StandardResponse.ok(responseParsed.data) + } catch (error) { console.warn(error) return StandardResponse.internalServerError() } diff --git a/app/routes/docs.tsx b/app/routes/docs.tsx index 95442c3a..b808a15f 100644 --- a/app/routes/docs.tsx +++ b/app/routes/docs.tsx @@ -1,46 +1,80 @@ +import { useCallback, useState } from 'react' import { useLoaderData } from 'react-router' import SwaggerUI from 'swagger-ui-react' import 'swagger-ui-react/swagger-ui.css' +import { createDocument } from 'zod-openapi' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '~/components/ui/select' +import { + generateIntegrationApiSpec, + generateOpenApiPathsSpec, + generateOpenApiServerSpec, +} from '~/lib/openapi' -export const loader = async ({ request }: { request: Request }) => { - if (process.env.NODE_ENV === 'production') { - const url = new URL(request.url) - const res = await fetch(new URL('/openapi.json', url.origin)) - if (!res.ok) - throw new Response('Failed to load OpenAPI spec', { status: 500 }) - const spec = await res.json() - return Response.json({ spec }) - } +export const loader = () => { + const doc = createDocument({ + openapi: '3.1.0', + info: { + title: 'openSenseMap API', + version: '1.0.0', + license: { + name: 'Public Domain Dedication and License 1.0.', + identifier: 'PDDL', + url: 'https://opendatacommons.org/licenses/pddl/summary/', + }, + }, + servers: generateOpenApiServerSpec(), + paths: generateOpenApiPathsSpec(), + }) - const { combinedOpenapiSpecification } = - await import('~/lib/openapi.combined') + const integration = createDocument({ ...generateIntegrationApiSpec() }) - return Response.json({ - spec: combinedOpenapiSpecification(), - }) + return { spec: doc, integrationSpec: integration } } export default function ApiDocumentation() { - const { spec } = useLoaderData() + const { spec, integrationSpec } = useLoaderData() + const [currentSpec, setCurrentSpec] = useState(spec) + + const handleSpecSelect = useCallback( + (value: string): void => { + if (value === spec.info.title) setCurrentSpec(spec) + if (value === integrationSpec.info.title) setCurrentSpec(integrationSpec) + }, + [setCurrentSpec, spec, integrationSpec], + ) return (
API Image
+
+

Choose API:

+
- =10" - } - }, - "node_modules/@apidevtools/swagger-methods": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@apidevtools/swagger-methods/-/swagger-methods-3.0.2.tgz", - "integrity": "sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==", - "license": "MIT" - }, - "node_modules/@apidevtools/swagger-parser": { - "version": "10.0.3", - "resolved": "https://registry.npmjs.org/@apidevtools/swagger-parser/-/swagger-parser-10.0.3.tgz", - "integrity": "sha512-sNiLY51vZOmSPFZA5TF35KZ2HbgYklQnTSDnkghamzLb3EkNtcQnrBQEj5AOCxHpTtXpqMCRM1CrmV2rG6nw4g==", - "license": "MIT", - "dependencies": { - "@apidevtools/json-schema-ref-parser": "^9.0.6", - "@apidevtools/openapi-schemas": "^2.0.4", - "@apidevtools/swagger-methods": "^3.0.2", - "@jsdevtools/ono": "^7.1.3", - "call-me-maybe": "^1.0.1", - "z-schema": "^5.0.1" - }, - "peerDependencies": { - "openapi-types": ">=7" - } - }, "node_modules/@asamuzakjp/css-color": { "version": "5.1.11", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", @@ -3621,12 +3577,6 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@jsdevtools/ono": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", - "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", - "license": "MIT" - }, "node_modules/@kurkle/color": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", @@ -9689,13 +9639,6 @@ "@types/geojson": "*" } }, - "node_modules/@types/swagger-jsdoc": { - "version": "6.0.4", - "resolved": "https://registry.npmjs.org/@types/swagger-jsdoc/-/swagger-jsdoc-6.0.4.tgz", - "integrity": "sha512-W+Xw5epcOZrF/AooUM/PccNMSAFOKWZA5dasNyMujTwsBkU74njSJBpvCCJhHAJ95XRMzQrrW844Btu0uoetwQ==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/swagger-ui-react": { "version": "5.18.0", "resolved": "https://registry.npmjs.org/@types/swagger-ui-react/-/swagger-ui-react-5.18.0.tgz", @@ -10350,6 +10293,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, "license": "MIT" }, "node_modules/base64-js": { @@ -10666,12 +10610,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/call-me-maybe": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz", - "integrity": "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==", - "license": "MIT" - }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -11060,6 +10998,7 @@ "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, "license": "MIT" }, "node_modules/conf": { @@ -11778,18 +11717,6 @@ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "license": "MIT" }, - "node_modules/doctrine": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", - "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", - "license": "Apache-2.0", - "dependencies": { - "esutils": "^2.0.2" - }, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/dom-accessibility-api": { "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", @@ -12944,15 +12871,6 @@ "@types/estree": "^1.0.0" } }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -13391,12 +13309,6 @@ "js-yaml": "bin/js-yaml.js" } }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "license": "ISC" - }, "node_modules/fsevents": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", @@ -14136,17 +14048,6 @@ "node": ">=12" } }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "license": "ISC", - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -15294,13 +15195,6 @@ "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", "license": "MIT" }, - "node_modules/lodash.get": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", - "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", - "deprecated": "This package is deprecated. Use the optional chaining (?.) operator instead.", - "license": "MIT" - }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -15313,13 +15207,6 @@ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", "license": "MIT" }, - "node_modules/lodash.isequal": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", - "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", - "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", - "license": "MIT" - }, "node_modules/lodash.isinteger": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", @@ -15350,12 +15237,6 @@ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "license": "MIT" }, - "node_modules/lodash.mergewith": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz", - "integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==", - "license": "MIT" - }, "node_modules/lodash.once": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", @@ -16437,15 +16318,6 @@ "node": ">= 0.8" } }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, "node_modules/openapi-path-templating": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/openapi-path-templating/-/openapi-path-templating-2.2.1.tgz", @@ -16474,8 +16346,8 @@ "version": "12.1.3", "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==", - "license": "MIT", - "peer": true + "dev": true, + "license": "MIT" }, "node_modules/outvariant": { "version": "1.4.3", @@ -16706,15 +16578,6 @@ "node": ">=14.0.0" } }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -19793,99 +19656,6 @@ "ramda-adjunct": "^5.1.0" } }, - "node_modules/swagger-jsdoc": { - "version": "6.2.8", - "resolved": "https://registry.npmjs.org/swagger-jsdoc/-/swagger-jsdoc-6.2.8.tgz", - "integrity": "sha512-VPvil1+JRpmJ55CgAtn8DIcpBs0bL5L3q5bVQvF4tAW/k/9JYSj7dCpaYCAv5rufe0vcCbBRQXGvzpkWjvLklQ==", - "license": "MIT", - "dependencies": { - "commander": "6.2.0", - "doctrine": "3.0.0", - "glob": "7.1.6", - "lodash.mergewith": "^4.6.2", - "swagger-parser": "^10.0.3", - "yaml": "2.0.0-1" - }, - "bin": { - "swagger-jsdoc": "bin/swagger-jsdoc.js" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/swagger-jsdoc/node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/swagger-jsdoc/node_modules/commander": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.0.tgz", - "integrity": "sha512-zP4jEKbe8SHzKJYQmq8Y9gYjtO/POJLgIdKgV7B9qNmABVFVc+ctqSX6iXh4mCpJfRBOabiZ2YKPg8ciDw6C+Q==", - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/swagger-jsdoc/node_modules/glob": { - "version": "7.1.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", - "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.0.4", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/swagger-jsdoc/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/swagger-jsdoc/node_modules/yaml": { - "version": "2.0.0-1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.0.0-1.tgz", - "integrity": "sha512-W7h5dEhywMKenDJh2iX/LABkbFnBxasD27oyXWDS/feDsxiw0dD5ncXdYXgkvAsXIY2MpW/ZKkr9IU30DBdMNQ==", - "license": "ISC", - "engines": { - "node": ">= 6" - } - }, - "node_modules/swagger-parser": { - "version": "10.0.3", - "resolved": "https://registry.npmjs.org/swagger-parser/-/swagger-parser-10.0.3.tgz", - "integrity": "sha512-nF7oMeL4KypldrQhac8RyHerJeGPD1p2xDh900GPvc+Nk7nWP6jX2FcC7WmkinMoAmoO774+AFXcWsW8gMWEIg==", - "license": "MIT", - "dependencies": { - "@apidevtools/swagger-parser": "10.0.3" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/swagger-ui-react": { "version": "5.32.4", "resolved": "https://registry.npmjs.org/swagger-ui-react/-/swagger-ui-react-5.32.4.tgz", @@ -20833,15 +20603,6 @@ "spdx-expression-parse": "^3.0.0" } }, - "node_modules/validator": { - "version": "13.15.35", - "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.35.tgz", - "integrity": "sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==", - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -21618,12 +21379,6 @@ "node": ">=8" } }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "license": "ISC" - }, "node_modules/ws": { "version": "8.18.3", "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", @@ -21787,36 +21542,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/z-schema": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/z-schema/-/z-schema-5.0.5.tgz", - "integrity": "sha512-D7eujBWkLa3p2sIpJA0d1pr7es+a7m0vFAnZLlCEKq/Ij2k0MLi9Br2UPxoxdYystm5K1yeBGzub0FlYUEWj2Q==", - "license": "MIT", - "dependencies": { - "lodash.get": "^4.4.2", - "lodash.isequal": "^4.5.0", - "validator": "^13.7.0" - }, - "bin": { - "z-schema": "bin/z-schema" - }, - "engines": { - "node": ">=8.0.0" - }, - "optionalDependencies": { - "commander": "^9.4.1" - } - }, - "node_modules/z-schema/node_modules/commander": { - "version": "9.5.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", - "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", - "license": "MIT", - "optional": true, - "engines": { - "node": "^12.20.0 || >=14" - } - }, "node_modules/zenscroll": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/zenscroll/-/zenscroll-4.0.2.tgz", @@ -21844,6 +21569,21 @@ "peerDependencies": { "zod": ">= 3.25.0" } + }, + "node_modules/zod-openapi": { + "version": "5.4.6", + "resolved": "https://registry.npmjs.org/zod-openapi/-/zod-openapi-5.4.6.tgz", + "integrity": "sha512-P2jsOOBAq/6hCwUsMCjUATZ8szkMsV5VAwZENfyxp2Hc/XPJQpVwAgevWZc65xZauCwWB9LAn7zYeiCJFAEL+A==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/samchungy/zod-openapi?sponsor=1" + }, + "peerDependencies": { + "zod": "^3.25.74 || ^4.0.0" + } } } } diff --git a/package.json b/package.json index b80ed3fb..5b5d9292 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,6 @@ "build": "run-s build:*", "build:drizzle": "npm run db:generate", "build:react-router": "react-router build", - "build:docs": "tsx ./scripts/generate-openapi.ts", "start": "cross-env NODE_ENV=production react-router-serve ./build/server/index.js", "test": "vitest", "test:ui": "vitest --ui", @@ -91,6 +90,7 @@ "drizzle-orm": "^0.45.2", "framer-motion": "^12.38.0", "get-user-locale": "^3.0.0", + "glob": "^13.0.6", "i18next": "^26.0.6", "i18next-browser-languagedetector": "^8.2.1", "i18next-fs-backend": "^2.6.4", @@ -120,7 +120,6 @@ "remix-i18next": "^7.5.0", "rollup-preserve-directives": "^1.1.3", "simple-statistics": "^7.8.9", - "swagger-jsdoc": "^6.2.8", "swagger-ui-react": "^5.32.4", "tailwind-merge": "^3.5.0", "tailwindcss-animate": "^1.0.7", @@ -129,7 +128,8 @@ "vaul": "^1.1.2", "vite-plugin-markdown": "^2.2.0", "zod": "^4.3.6", - "zod-form-data": "^3.0.1" + "zod-form-data": "^3.0.1", + "zod-openapi": "^5.4.6" }, "devDependencies": { "@epic-web/config": "^3.1.0", @@ -154,7 +154,6 @@ "@types/react": "^19.2.14", "@types/react-dom": "19.2.3", "@types/supercluster": "^7.1.3", - "@types/swagger-jsdoc": "^6.0.4", "@types/swagger-ui-react": "^5.18.0", "@vitejs/plugin-react": "^6.0.1", "@vitest/coverage-v8": "^4.1.5", diff --git a/public/integration-api.json b/public/integration-api.json new file mode 100644 index 00000000..3987dd73 --- /dev/null +++ b/public/integration-api.json @@ -0,0 +1,8 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "My API", + "version": "1.0.0" + }, + "paths": {} +} \ No newline at end of file diff --git a/scripts/generate-openapi.ts b/scripts/generate-openapi.ts deleted file mode 100644 index 026f6eff..00000000 --- a/scripts/generate-openapi.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { writeFileSync } from 'node:fs' -import { combinedOpenapiSpecification } from '../app/lib/openapi.combined.js' - -writeFileSync( - './public/openapi.json', - JSON.stringify(combinedOpenapiSpecification(), null, 2), -) - -console.info('✅ OpenAPI spec generated')