diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index ba8fdece5..7f19ca4ac 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 b80cdd226..2d38df515 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/README.md b/README.md index 59df44d9e..e5fa4df88 100644 --- a/README.md +++ b/README.md @@ -163,91 +163,151 @@ flexibility to adjust the outputs to the needs of the respective use case. ##### Documenting an API Route -The [swaggerJsdoc Library](https://www.npmjs.com/package/swagger-jsdoc) reads -the JSDoc-annotated source code in the api-routes and generates an -openAPI(Swagger) specification and is rendered using -[Swaggger UI](https://swagger.io/tools/swagger-ui/). The -[JSDoc annotations](https://github.com/Surnet/swagger-jsdoc) is usually added -before the loader or action function in the API Routes. The documentation will -then be automatically generated from the JSDoc annotations in all the api -routes. When testing the api during development do not forget to change the -server to [Development Server](http://localhost:3000). To authorize a user you -must provide the token obtained after sign-in. You can just copy and paste the -token in the value field and then hit the authorize button. - -##### JSDoc Example - -Here's an example of how to document an API route using JSDoc annotations: - -```javascript -/** - * @openapi - * /api/users/{id}: - * get: - * summary: Get user by ID - * description: Retrieve a single user by their unique identifier - * tags: - * - Users - * parameters: - * - in: path - * name: id - * required: true - * description: Unique identifier of the user - * schema: - * type: string - * example: "12345" - * responses: - * 200: - * description: User retrieved successfully - * content: - * application/json: - * schema: - * type: object - * properties: - * id: - * type: string - * example: "12345" - * name: - * type: string - * example: "John Doe" - * email: - * type: string - * example: "john.doe@example.com" - * createdAt: - * type: string - * format: date-time - * example: "2023-01-15T10:30:00Z" - * 404: - * description: User not found - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: "User not found" - * 500: - * description: Internal server error - */ -export async function loader({ params }) { +API route documentation is generated from route-local `zod-openapi` +definitions. Each API route can export an `openapi` object that describes the +route's OpenAPI path item. Request bodies, response bodies, path parameters, +query parameters, and headers should be described with Zod schemas wherever +possible. + +The main benefit of this approach is that schemas can be shared between +validation and documentation. This keeps the OpenAPI documentation closer to the +actual implementation and reduces the risk of outdated docs. + +The generated OpenAPI specification is rendered using +[Swagger UI](https://swagger.io/tools/swagger-ui/). When testing the API during +development, do not forget to change the server to the +[Development Server](http://localhost:3000). To authorize a user, provide the +token obtained after sign-in. You can copy and paste the token into the value +field and then hit the authorize button. + +##### zod-openapi Example + +Here's an example of how to document an API route using `zod-openapi`: + +```typescript +import { z } from 'zod' +import { type ZodOpenApiPathItemObject } from 'zod-openapi' +import { type Route } from './+types/api.users.$id' + +const UserPathParamsSchema = z.object({ + id: z.string().min(1).meta({ + description: 'Unique identifier of the user', + example: '12345', + }), +}) + +const UserSchema = z + .object({ + id: z.string().meta({ + description: 'Unique identifier of the user', + example: '12345', + }), + name: z.string().meta({ + description: "User's display name", + example: 'John Doe', + }), + email: z.string().email().meta({ + description: "User's email address", + example: 'john.doe@example.com', + }), + createdAt: z.string().datetime().meta({ + description: 'Account creation timestamp', + example: '2023-01-15T10:30:00.000Z', + }), + }) + .meta({ + id: 'User', + description: 'User information object', + }) + +const NotFoundErrorSchema = z + .object({ + code: z.literal('Not Found'), + message: z.literal('User not found'), + error: z.literal('User not found'), + }) + .meta({ id: 'NotFoundError' }) + +const InternalServerErrorSchema = z + .object({ + code: z.literal('Internal Server Error'), + message: z.literal('Internal server error'), + error: z.literal('Internal server error'), + }) + .meta({ id: 'InternalServerError' }) + +export const openapi: ZodOpenApiPathItemObject = { + get: { + tags: ['Users'], + summary: 'Get user by ID', + description: 'Retrieve a single user by their unique identifier', + operationId: 'getUserById', + + requestParams: { + path: UserPathParamsSchema, + }, + + responses: { + 200: { + description: 'User retrieved successfully', + content: { + 'application/json': { + schema: UserSchema, + }, + }, + }, + 404: { + description: 'User not found', + content: { + 'application/json': { + schema: NotFoundErrorSchema, + }, + }, + }, + 500: { + description: 'Internal server error', + content: { + 'application/json': { + schema: InternalServerErrorSchema, + }, + }, + }, + }, + }, +} + +export async function loader({ params }: Route.LoaderArgs) { const { id } = params try { const user = await getUserById(id) + if (!user) { - throw new Response('User not found', { status: 404 }) + return StandardResponse.notFound('User not found') + } + + const parsed = await UserSchema.safeParseAsync(user) + + if (!parsed.success) { + return StandardResponse.internalServerError() } - return Response.json({ user }) + + return StandardResponse.ok(parsed.data) } catch (error) { - throw new Response('Internal server error', { status: 500 }) + console.warn(error) + return StandardResponse.internalServerError('Internal server error') } } ``` -This JSDoc annotation will automatically generate comprehensive API -documentation including endpoint details, parameters, response schemas, and -example values. +The exported `openapi` object is picked up automatically when generating the +OpenAPI specification. Prefer defining reusable Zod schemas for request bodies, +response bodies, path parameters, query parameters, and shared error responses. + +Use `.meta(...)` to add OpenAPI-specific metadata such as descriptions, +examples, component ids, and formatting hints. For route parameters, prefer +`requestParams` over manually writing OpenAPI `parameters`, because it allows +the parameters to be described directly with Zod schemas. #### Testing diff --git a/app/lib/api-schemas/query.ts b/app/lib/api-schemas/query.ts new file mode 100644 index 000000000..7b8d894f6 --- /dev/null +++ b/app/lib/api-schemas/query.ts @@ -0,0 +1,112 @@ +import * as z from 'zod/v4' +import { IsoDateTimeSchema } from '../openapi/schemas/common' + +export const ExposureSchema = z + .enum(['indoor', 'outdoor', 'mobile', 'unknown']) + .meta({ + id: 'Exposure', + description: 'Device exposure.', + example: 'outdoor', + }) + +export const OutputFormatSchema = z.enum(['json', 'csv']).default('json').meta({ + id: 'OutputFormat', + description: 'Response format. Can be json or csv. Defaults to json.', + example: 'json', +}) + +export const JsonGeoJsonFormatSchema = z + .enum(['json', 'geojson']) + .default('json') + .meta({ + id: 'JsonGeoJsonOutputFormat', + description: 'Response format. Can be json or geojson. Defaults to json.', + example: 'json', + }) + +export const CsvDelimiterValueSchema = z.enum(['comma', 'semicolon']).meta({ + id: 'CsvDelimiterValue', + description: 'CSV delimiter value.', + example: 'comma', +}) + +export const DelimiterSchema = CsvDelimiterValueSchema.default('comma').meta({ + id: 'Delimiter', + description: 'CSV delimiter. Defaults to `comma`.', + example: 'comma', +}) + +export const SeparatorSchema = CsvDelimiterValueSchema.optional().meta({ + id: 'Separator', + description: + 'Legacy alias for `delimiter`. Do not use together with `delimiter`.', + example: 'semicolon', +}) + +export const DownloadSchema = z + .union([z.boolean(), z.enum(['true', 'false'])]) + .optional() + .meta({ + id: 'Download', + description: + 'If true, the response includes a Content-Disposition download header.', + example: true, + }) + +export const QueryDownloadSchema = z.enum(['true', 'false']).optional().meta({ + id: 'QueryDownload', + description: + 'If true, the response includes a Content-Disposition download header.', + example: 'true', +}) + +export const BboxTupleSchema = z + .tuple([z.number(), z.number(), z.number(), z.number()]) + .meta({ + id: 'BboxTuple', + description: 'Bounding box as [lngSW, latSW, lngNE, latNE].', + example: [7.0, 51.0, 8.0, 52.0], + }) + +export const QueryBboxSchema = z.string().optional().meta({ + id: 'QueryBbox', + description: + 'Bounding box as comma-separated coordinates: lngSW,latSW,lngNE,latNE.', + example: '7.0,51.0,8.0,52.0', +}) + +export const BboxSchema = z + .union([QueryBboxSchema.unwrap(), BboxTupleSchema]) + .optional() + .meta({ + id: 'Bbox', + description: + 'Bounding box as comma-separated string or [lngSW, latSW, lngNE, latNE].', + }) + +export const DateRangeQuerySchema = z.object({ + 'from-date': IsoDateTimeSchema.optional().meta({ + description: 'Beginning of the time range.', + example: '2026-05-13T12:00:00.000Z', + }), + 'to-date': IsoDateTimeSchema.optional().meta({ + description: 'End of the time range.', + example: '2026-05-15T12:00:00.000Z', + }), +}) + +export const ColumnsQuerySchema = z.string().optional().meta({ + id: 'ColumnsQuery', + description: 'Comma-separated list of columns to include in the output.', + example: 'createdAt,value,boxId,boxName,sensorId,phenomenon,unit,lat,lon', +}) + +export const ColumnsBodySchema = z + .union([z.string(), z.array(z.string())]) + .optional() + .meta({ + id: 'Columns', + description: + 'Columns to include in the output, either as comma-separated string or array.', + example: ['createdAt', 'value', 'boxId', 'sensorId'], + }) diff --git a/app/lib/env.server.ts b/app/lib/env.server.ts index 6d80ad9f8..6999b77b2 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 e58140bce..000000000 --- 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/jwt.ts b/app/lib/jwt.ts index 338bbad75..9051e747f 100644 --- a/app/lib/jwt.ts +++ b/app/lib/jwt.ts @@ -7,6 +7,8 @@ import { getUserByEmail } from '~/db/models/user.server' import { type Device, type User } from '~/db/schema' import { refreshToken, tokenRevocation } from '~/db/schema/refreshToken' import { drizzleClient } from '~/db.server' +import { StandardResponse } from './responses' +import { apiMessages } from './openapi/messages' const { sign, verify } = jsonwebtoken @@ -147,6 +149,16 @@ export const getUserFromJwt = async ( return user } +export const getAuthenticatedUser = async (request: Request) => { + const jwtResponse = await getUserFromJwt(request) + + if (typeof jwtResponse === 'string') { + return StandardResponse.forbidden(apiMessages.invalidJwt) + } + + return jwtResponse +} + const decodeJwtString = ( jwtString: string, jwtSecret: jsonwebtoken.Secret, diff --git a/app/lib/openapi.combined.ts b/app/lib/openapi.combined.ts deleted file mode 100644 index ba3da16f5..000000000 --- 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 0ce373b18..8bf46d196 100644 --- a/app/lib/openapi.ts +++ b/app/lib/openapi.ts @@ -1,135 +1,71 @@ -import swaggerJsdoc from 'swagger-jsdoc' +import { ZodOpenApiPathItemObject, ZodOpenApiPathsObject } from 'zod-openapi' +import { generateIntegrationApiSpec } from './openapi/integrations' const DEV_SERVER = { url: 'http://localhost:3000', description: 'Development server', } -const options: swaggerJsdoc.Options = { - definition: { - openapi: '3.0.0', - info: { - title: 'openSenseMap API', - 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 - -## Please note that the API handles every timestamp in Coordinated universal time (UTC) time zone. Timestamps in parameters should be in RFC 3339 notation. - -**Timestamp without Milliseconds:** - -\`\`\` -2018-02-01T23:18:02Z -\`\`\` - -**Timestamp with Milliseconds:** - -\`\`\` -2018-02-01T23:18:02.412Z -\`\`\` - -# IDs - -## 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. - -**Example:** +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}` +} -\`\`\` -5a8d1c25bc2d41001927a265 -\`\`\` +export const generateOpenApiPathsSpec = (): ZodOpenApiPathsObject => { + const routes = import.meta.glob<{ + openapi?: ZodOpenApiPathItemObject + [key: string]: any + }>('/app/routes/api.*.ts', { eager: true }) -# Parameters + const paths: ZodOpenApiPathsObject = {} -## 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. + for (const [filePath, module] of Object.entries(routes)) { + if (!module.openapi) continue -# Source code and Licenses + const apiPath = convertFilePathToApiPath(filePath) -## 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. + // Merge methods into path + paths[apiPath] = { + ...paths[apiPath], + ...module.openapi, + } + } -## 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/). + return paths +} -## 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.`, - }, - 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', - }, - ], - 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'], - }, - }, - parameters: { - SenseBoxIdParam: { - name: 'id', - in: 'path', - required: true, - schema: { - $ref: '#/components/schemas/SenseBoxId', - }, - description: 'SenseBox ID parameter', - }, - TimestampParam: { - name: 'timestamp', - in: 'query', - schema: { - $ref: '#/components/schemas/Timestamp', - }, - description: 'Timestamp parameter in RFC 3339 format (UTC)', - }, - }, - responses: { - BadRequest: { - description: 'Bad Request - Invalid parameters or payload', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - error: { - type: 'string', - example: 'Invalid request parameters', - }, - }, - }, - }, - }, - }, - NotFound: { - description: 'Resource not found', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - error: { - type: 'string', - example: 'Resource not found', - }, - }, - }, - }, - }, - }, - }, +export const generateOpenApiServerSpec = (): { + url: string + description: string +}[] => { + return [ + ...(process.env.NODE_ENV !== 'production' ? [DEV_SERVER] : []), + { + url: process.env.OSEM_API_URL, + description: 'Production server', }, - }, - // Path to the API routes containing JSDoc annotations - apis: ['./app/routes/api.*.ts'], // Adjust path as needed + ] } -export const openapiSpecification = () => swaggerJsdoc(options) +export { generateIntegrationApiSpec } diff --git a/app/lib/openapi/errors/factories.ts b/app/lib/openapi/errors/factories.ts new file mode 100644 index 000000000..b4c68d091 --- /dev/null +++ b/app/lib/openapi/errors/factories.ts @@ -0,0 +1,61 @@ +import * as z from 'zod/v4' +import 'zod-openapi' + +import { standardErrorResponseSchema } from './schemas' + +type StandardErrorSchemaOptions = { + code: Code + id: string + description?: string + examples?: string[] + messageSchema?: z.ZodType +} + +export const createStandardErrorSchema = ({ + code, + id, + description, + examples, + messageSchema, +}: StandardErrorSchemaOptions) => + standardErrorResponseSchema( + code, + messageSchema ?? + z.string().meta({ + examples, + }), + ).meta({ + id, + description, + }) + +export const createStandardErrorSchemaFactory = + (code: Code) => + (options: Omit, 'code'>) => + createStandardErrorSchema({ + code, + ...options, + }) + +export const createBadRequestErrorSchema = + createStandardErrorSchemaFactory('Bad Request') + +export const createUnauthorizedErrorSchema = + createStandardErrorSchemaFactory('Unauthorized') + +export const createForbiddenErrorSchema = + createStandardErrorSchemaFactory('Forbidden') + +export const createNotFoundErrorSchema = + createStandardErrorSchemaFactory('Not Found') + +export const createConflictErrorSchema = + createStandardErrorSchemaFactory('Conflict') + +export const createUnprocessableContentErrorSchema = + createStandardErrorSchemaFactory('Unprocessable Content') + +export const createUnsupportedMediaTypeErrorSchema = + createStandardErrorSchemaFactory('Unsupported Media Type') + +export const createGoneErrorSchema = createStandardErrorSchemaFactory('Gone') diff --git a/app/lib/openapi/errors/index.ts b/app/lib/openapi/errors/index.ts new file mode 100644 index 000000000..adf838c4b --- /dev/null +++ b/app/lib/openapi/errors/index.ts @@ -0,0 +1,3 @@ +export * from './schemas' +export * from './factories' +export * from './responses' diff --git a/app/lib/openapi/errors/responses.ts b/app/lib/openapi/errors/responses.ts new file mode 100644 index 000000000..7893bd43d --- /dev/null +++ b/app/lib/openapi/errors/responses.ts @@ -0,0 +1,71 @@ +import { z, type ZodType } from 'zod/v4' + +export const MessageResponseSchema = z + .object({ + message: z.string().meta({ + example: 'Operation completed successfully.', + }), + }) + .meta({ + id: 'MessageResponse', + }) + +export const jsonResponse = (description: string, schema: ZodType) => ({ + description, + content: { + 'application/json': { + schema, + }, + }, +}) + +export const messageResponse = ( + description = 'Operation completed successfully.', +) => jsonResponse(description, MessageResponseSchema) + +export const jsonErrorResponse = (description: string, schema: ZodType) => + jsonResponse(description, schema) + +export const badRequestResponse = ( + schema: ZodType, + description = 'Bad request.', +) => jsonErrorResponse(description, schema) + +export const unauthorizedResponse = ( + schema: ZodType, + description = 'Unauthorized.', +) => jsonErrorResponse(description, schema) + +export const forbiddenResponse = ( + schema: ZodType, + description = 'Forbidden.', +) => jsonErrorResponse(description, schema) + +export const notFoundResponse = (schema: ZodType, description = 'Not found.') => + jsonErrorResponse(description, schema) + +export const conflictResponse = (schema: ZodType, description = 'Conflict.') => + jsonErrorResponse(description, schema) + +export const unprocessableContentResponse = ( + schema: ZodType, + description = 'Unprocessable content.', +) => jsonErrorResponse(description, schema) + +export const unsupportedMediaTypeResponse = ( + schema: ZodType, + description = 'Unsupported media type.', +) => jsonErrorResponse(description, schema) + +export const internalServerErrorResponse = ( + schema: ZodType, + description = 'Internal server error.', +) => jsonErrorResponse(description, schema) + +export const methodNotAllowedResponse = ( + schema: ZodType, + description = 'Method not allowed.', +) => jsonErrorResponse(description, schema) + +export const goneResponse = (schema: ZodType, description = 'Gone.') => + jsonErrorResponse(description, schema) diff --git a/app/lib/openapi/errors/schemas.ts b/app/lib/openapi/errors/schemas.ts new file mode 100644 index 000000000..9bff3a5fb --- /dev/null +++ b/app/lib/openapi/errors/schemas.ts @@ -0,0 +1,91 @@ +import * as z from 'zod/v4' +import 'zod-openapi' + +export const standardErrorResponseSchema = ( + code: Code, + messageSchema: z.ZodType = z.string(), +) => + z.object({ + code: z.literal(code), + message: messageSchema, + error: messageSchema, + }) + +export const BadRequestErrorSchema = standardErrorResponseSchema( + 'Bad Request', + z.string().meta({ example: 'Bad request.' }), +).meta({ + id: 'BadRequestError', +}) + +export const ConflictErrorSchema = standardErrorResponseSchema( + 'Conflict', + z.string().meta({ example: 'Conflict.' }), +).meta({ + id: 'ConflictError', +}) + +export const UnauthorizedErrorSchema = standardErrorResponseSchema( + 'Unauthorized', + z.string().meta({ example: 'Unauthorized.' }), +).meta({ + id: 'UnauthorizedError', +}) + +export const UnprocessableContentErrorSchema = standardErrorResponseSchema( + 'Unprocessable Content', + z.string().meta({ example: 'Unprocessable content.' }), +).meta({ + id: 'UnprocessableContentError', +}) + +export const UnsupportedMediaTypeErrorSchema = standardErrorResponseSchema( + 'Unsupported Media Type', + z.string().meta({ + example: 'Unsupported content-type. Try application/json', + }), +).meta({ + id: 'UnsupportedMediaTypeError', + description: 'Generic unsupported media type response.', +}) + +export const ForbiddenErrorSchema = standardErrorResponseSchema( + 'Forbidden', + z.string().meta({ example: 'Forbidden.' }), +).meta({ + id: 'ForbiddenError', +}) + +export const NotFoundErrorSchema = standardErrorResponseSchema( + 'Not Found', + z.string().meta({ example: 'Resource not found.' }), +).meta({ + id: 'NotFoundError', +}) + +export const InternalServerErrorSchema = standardErrorResponseSchema( + 'Internal Server Error', + z.string().meta({ + example: + 'The server was unable to complete your request. Please try again later.', + }), +).meta({ + id: 'InternalServerError', +}) + +export const MethodNotAllowedErrorSchema = standardErrorResponseSchema( + 'Method not allowed', + z.string().meta({ example: 'Method not allowed.' }), +).meta({ + id: 'MethodNotAllowedError', +}) + +export const GoneErrorSchema = standardErrorResponseSchema( + 'Gone', + z.string().meta({ + example: 'The requested resource is no longer available.', + }), +).meta({ + id: 'GoneError', + description: 'Generic gone response.', +}) diff --git a/app/lib/openapi/integrations/apis.ts b/app/lib/openapi/integrations/apis.ts new file mode 100644 index 000000000..5d483db23 --- /dev/null +++ b/app/lib/openapi/integrations/apis.ts @@ -0,0 +1,202 @@ +import type { ZodOpenApiPathsObject } from 'zod-openapi' +import { + healthResponseExample, + mqttConfigRequestExample, + ttnConfigRequestExample, + mqttJsonSchemaExample, +} from './examples' + +export const integrationPaths: ZodOpenApiPathsObject = { + '/integrations/{deviceId}': { + get: { + summary: 'Get integration configuration for a device', + description: + 'Returns the integration configuration associated with the given openSenseMap device ID.', + operationId: 'getIntegration', + tags: ['Integration Management'], + parameters: [ + { + $ref: '#/components/parameters/DeviceId', + }, + ], + responses: { + '200': { + description: 'Integration configuration returned successfully.', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/IntegrationConfig', + }, + }, + }, + }, + '401': { + $ref: '#/components/responses/Unauthorized', + }, + '404': { + $ref: '#/components/responses/NotFound', + }, + '500': { + $ref: '#/components/responses/InternalError', + }, + }, + }, + + put: { + summary: 'Create or update integration configuration', + description: + 'Creates or updates the integration configuration for the given openSenseMap device ID.', + operationId: 'createOrUpdateIntegration', + tags: ['Integration Management'], + parameters: [ + { + $ref: '#/components/parameters/DeviceId', + }, + ], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/IntegrationConfigInput', + }, + examples: { + mqtt: { + summary: 'MQTT integration configuration', + value: mqttConfigRequestExample, + }, + ttn: { + summary: 'TTN integration configuration', + value: ttnConfigRequestExample, + }, + }, + }, + }, + }, + responses: { + '200': { + description: 'Integration configuration updated successfully.', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/IntegrationConfig', + }, + }, + }, + }, + '201': { + description: 'Integration configuration created successfully.', + 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', + description: + 'Deletes the integration configuration for the given openSenseMap device ID.', + operationId: 'deleteIntegration', + tags: ['Integration Management'], + parameters: [ + { + $ref: '#/components/parameters/DeviceId', + }, + ], + responses: { + '204': { + description: 'Integration configuration deleted successfully.', + }, + '401': { + $ref: '#/components/responses/Unauthorized', + }, + '404': { + $ref: '#/components/responses/NotFound', + }, + '500': { + $ref: '#/components/responses/InternalError', + }, + }, + }, + }, + + '/integrations/schema/{integrationName}': { + get: { + summary: 'Get JSON Schema for integration configuration form', + description: + 'Returns a JSON Schema and optional UI Schema for rendering a configuration form for the requested integration type.', + operationId: 'getIntegrationSchema', + tags: ['Schema'], + parameters: [ + { + $ref: '#/components/parameters/IntegrationName', + }, + ], + responses: { + '200': { + description: 'JSON Schema returned successfully.', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/JsonSchemaResponse', + }, + examples: { + mqtt: { + summary: 'MQTT schema example', + value: mqttJsonSchemaExample, + }, + }, + }, + }, + }, + '401': { + $ref: '#/components/responses/Unauthorized', + }, + '404': { + $ref: '#/components/responses/NotFound', + }, + '500': { + $ref: '#/components/responses/InternalError', + }, + }, + }, + }, + + '/health': { + get: { + summary: 'Health check endpoint', + description: + 'Returns the health status of the integration service. This endpoint does not require authentication.', + operationId: 'healthCheck', + tags: ['Health'], + security: [], + responses: { + '200': { + description: 'Service is healthy.', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/HealthResponse', + }, + example: healthResponseExample, + }, + }, + }, + }, + }, + }, +} diff --git a/app/lib/openapi/integrations/components.ts b/app/lib/openapi/integrations/components.ts new file mode 100644 index 000000000..faf01c519 --- /dev/null +++ b/app/lib/openapi/integrations/components.ts @@ -0,0 +1,178 @@ +import type { ZodOpenApiObject } from 'zod-openapi' +import { + errorExamples, + mqttIntegrationConfigExample, + mqttJsonSchemaExample, +} from './examples' + +type OpenApiComponents = NonNullable + +export const integrationComponents = { + 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', + }, + + IntegrationName: { + name: 'integrationName', + in: 'path', + required: true, + schema: { + type: 'string', + }, + description: 'Name of the integration type.', + example: 'mqtt', + }, + }, + + schemas: { + IntegrationConfig: { + type: 'object', + description: + 'Integration configuration. Common metadata fields may be present; integration-specific configuration fields are allowed.', + properties: { + id: { + type: 'string', + description: 'Integration configuration ID.', + example: 'intg_123', + }, + deviceId: { + type: 'string', + description: 'openSenseMap device ID.', + example: 'cm65qexample123', + }, + enabled: { + type: 'boolean', + description: 'Whether the integration is enabled.', + example: true, + }, + }, + additionalProperties: true, + example: mqttIntegrationConfigExample, + }, + + IntegrationConfigInput: { + type: 'object', + description: + 'Configuration payload specific to the integration type. The exact shape should match the schema returned by /integrations/schema/{integrationName}.', + additionalProperties: true, + }, + + JsonSchemaResponse: { + type: 'object', + description: + 'JSON Schema and optional UI Schema used to render a dynamic configuration form.', + properties: { + schema: { + type: 'object', + description: 'JSON Schema definition.', + additionalProperties: true, + }, + uiSchema: { + type: 'object', + description: 'React JSON Schema Form UI Schema.', + additionalProperties: true, + }, + }, + required: ['schema'], + example: mqttJsonSchemaExample, + }, + + HealthResponse: { + type: 'object', + properties: { + status: { + type: 'string', + example: 'healthy', + }, + timestamp: { + type: 'string', + format: 'date-time', + }, + }, + required: ['status'], + }, + + Error: { + type: 'object', + properties: { + error: { + type: 'string', + }, + details: { + type: 'array', + items: { + type: 'string', + }, + }, + }, + required: ['error'], + }, + }, + + responses: { + NotFound: { + description: 'Resource not found.', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Error', + }, + example: errorExamples.notFound, + }, + }, + }, + + ValidationError: { + description: 'Validation failed.', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Error', + }, + example: errorExamples.validation, + }, + }, + }, + + Unauthorized: { + description: 'Unauthorized. Invalid or missing service key.', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Error', + }, + example: errorExamples.unauthorized, + }, + }, + }, + + InternalError: { + description: 'Internal server error.', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Error', + }, + example: errorExamples.internalError, + }, + }, + }, + }, +} satisfies OpenApiComponents diff --git a/app/lib/openapi/integrations/description.ts b/app/lib/openapi/integrations/description.ts new file mode 100644 index 000000000..a682acac6 --- /dev/null +++ b/app/lib/openapi/integrations/description.ts @@ -0,0 +1,64 @@ +export const integrationApiDescription = ` +# OpenSenseMap Integration Service Contract + +This specification describes the HTTP endpoints an external integration service must implement so openSenseMap can manage integration configuration. + +An integration service is responsible for connecting external platforms or protocols, such as MQTT or TTN, to openSenseMap. + +## Architecture + +\`\`\` +openSenseMap Main App + ↓ manages configuration via HTTP +Integration Service + ↓ receives data from +External Platform / Protocol + ↓ forwards measurements to +openSenseMap Measurement API +\`\`\` + +## Required Endpoints + +An integration service should implement: + +1. \`GET /integrations/{deviceId}\` — get integration configuration for a device +2. \`PUT /integrations/{deviceId}\` — create or update integration configuration +3. \`DELETE /integrations/{deviceId}\` — delete integration configuration +4. \`GET /integrations/schema/{integrationName}\` — return JSON Schema for configuration forms +5. \`GET /health\` — health check + +## Authentication + +All endpoints except \`/health\` require the \`x-service-key\` header. + +## Forwarding Measurements + +After receiving and decoding data from the external platform, the integration service should forward measurements to the openSenseMap API. + +Recommended endpoint: + +\`POST /api/boxes/{deviceId}/data\` + +Headers: + +- \`Content-Type: application/json\` +- \`x-service-key: \` + +Example body: + +\`\`\`json +{ + "sensorId": [23.5, "2026-02-06T10:00:00Z", { "lng": 7.628, "lat": 51.963, "height": 100 }] +} +\`\`\` + +## Registration + +To register an integration, openSenseMap administrators need: + +- Service name and description +- Service URL +- Service authentication key +- Icon name +- JSON Schema endpoint path +` diff --git a/app/lib/openapi/integrations/examples.ts b/app/lib/openapi/integrations/examples.ts new file mode 100644 index 000000000..919f29b6f --- /dev/null +++ b/app/lib/openapi/integrations/examples.ts @@ -0,0 +1,87 @@ +export const mqttIntegrationConfigExample = { + id: 'intg_123', + deviceId: 'cm65qexample123', + enabled: true, + url: 'mqtt://broker.example.com', + topic: 'sensors/data', + messageFormat: 'json', + connectionOptions: { + username: 'user', + password: 'pass', + }, +} + +export const ttnIntegrationConfigExample = { + id: 'intg_456', + deviceId: 'cm65qexample123', + enabled: true, + devId: 'my-device', + appId: 'my-app', + profile: 'cayenne-lpp', +} + +export const mqttConfigRequestExample = { + url: 'mqtt://broker.example.com:1883', + topic: 'sensors/temperature', + messageFormat: 'json', + connectionOptions: { + username: 'user', + password: 'pass', + }, +} + +export const ttnConfigRequestExample = { + devId: 'my-device', + appId: 'my-app', + profile: 'cayenne-lpp', +} + +export const mqttJsonSchemaExample = { + 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'], + }, +} + +export const healthResponseExample = { + status: 'healthy', + timestamp: '2026-05-28T12:00:00.000Z', +} + +export const errorExamples = { + notFound: { + error: 'Integration not found', + }, + validation: { + error: 'Validation failed', + details: [ + 'url is required and must be a string', + 'topic is required and must be a string', + ], + }, + unauthorized: { + error: 'Unauthorized', + }, + internalError: { + error: 'Internal server error', + }, +} diff --git a/app/lib/openapi/integrations/index.ts b/app/lib/openapi/integrations/index.ts new file mode 100644 index 000000000..b458243b7 --- /dev/null +++ b/app/lib/openapi/integrations/index.ts @@ -0,0 +1,50 @@ +import type { ZodOpenApiObject } from 'zod-openapi' +import { integrationComponents } from './components' +import { integrationApiDescription } from './description' +import { integrationPaths } from './apis' + +const integrationServers = [ + { + url: 'https://your-integration-service.com', + description: 'Your integration microservice', + }, +] + +const integrationTags = [ + { + name: 'Integration Management', + description: 'CRUD operations for integration configurations.', + }, + { + name: 'Schema', + description: 'JSON Schema for dynamic configuration forms.', + }, + { + name: 'Health', + description: 'Service health check.', + }, +] + +export const generateIntegrationApiSpec = (): ZodOpenApiObject => ({ + openapi: '3.1.0', + + info: { + title: 'OpenSenseMap Integration Service Contract', + version: '1.0.0', + description: integrationApiDescription, + }, + + servers: integrationServers, + + components: integrationComponents, + + security: [ + { + ServiceKey: [], + }, + ], + + tags: integrationTags, + + paths: integrationPaths, +}) diff --git a/app/lib/openapi/messages.ts b/app/lib/openapi/messages.ts new file mode 100644 index 000000000..91aff1192 --- /dev/null +++ b/app/lib/openapi/messages.ts @@ -0,0 +1,16 @@ +export const apiMessages = { + deviceIdRequired: 'Device ID is required.', + deviceNotFound: 'Device not found.', + invalidJwt: 'Invalid JWT authorization. Please sign in to obtain a new JWT.', + internal: + 'The server was unable to complete your request. Please try again later.', + passwordRequired: 'Password is required for device deletion', + passwordIncorrect: 'Password incorrect', + invalidJson: 'Invalid JSON in request body', + invalidRequestData: 'Invalid request data', + invalidFormat: 'Invalid format parameter', + methodNotAllowed: 'Method Not Allowed', + tokenRequired: 'You must specify a token to refresh', + refreshTokenInvalid: + 'Refresh token invalid or too old. Please sign in with your username and password.', +} as const diff --git a/app/lib/openapi/schemas/auth.ts b/app/lib/openapi/schemas/auth.ts new file mode 100644 index 000000000..4b4ed96f4 --- /dev/null +++ b/app/lib/openapi/schemas/auth.ts @@ -0,0 +1,30 @@ +import * as z from 'zod/v4' +import 'zod-openapi' + +import { apiMessages } from '~/lib/openapi/messages' + +export const PasswordConfirmationRequestSchema = z + .object({ + password: z + .string() + .min(1, { + error: apiMessages.passwordRequired, + }) + .meta({ + description: 'Current user password required to confirm this action', + example: 'myCurrentPassword123', + format: 'password', + }), + }) + .meta({ + id: 'PasswordConfirmationRequest', + description: 'Password confirmation payload.', + }) + +export const BearerTokenSchema = z + .string() + .trim() + .regex(/^Bearer\s+\S+$/, { + error: apiMessages.refreshTokenInvalid, + }) + .transform((header) => header.split(/\s+/)[1]) diff --git a/app/lib/openapi/schemas/claim.ts b/app/lib/openapi/schemas/claim.ts new file mode 100644 index 000000000..0a07c133a --- /dev/null +++ b/app/lib/openapi/schemas/claim.ts @@ -0,0 +1,42 @@ +import * as z from 'zod/v4' +import 'zod-openapi' + +export const BoxTransferTokenSchema = z.string().min(1).meta({ + description: 'Transfer token.', + example: 'transfer-token-example', +}) + +export const BoxTransferClaimSchema = z + .object({ + id: z.string().meta({ + description: 'Unique transfer claim id.', + example: 'clm_01jv7c9x8n0example', + }), + + boxId: z.string().meta({ + description: 'ID of the box marked for transfer.', + example: '5bdbe70f55d0ad001a04edc9', + }), + + token: BoxTransferTokenSchema, + + expiresAt: z.iso.datetime().nullable().optional().meta({ + description: + 'Expiration date of the transfer token. If omitted, the token does not have an explicit expiration date.', + example: '2026-05-22T12:00:00.000Z', + }), + + createdAt: z.iso.datetime().meta({ + description: 'Transfer claim creation timestamp.', + example: '2026-05-21T12:00:00.000Z', + }), + + updatedAt: z.iso.datetime().meta({ + description: 'Transfer claim update timestamp.', + example: '2026-05-21T12:00:00.000Z', + }), + }) + .meta({ + id: 'BoxTransferClaim', + description: 'Transfer claim created for a box.', + }) diff --git a/app/lib/openapi/schemas/common.ts b/app/lib/openapi/schemas/common.ts new file mode 100644 index 000000000..45ac0caf7 --- /dev/null +++ b/app/lib/openapi/schemas/common.ts @@ -0,0 +1,30 @@ +import * as z from 'zod/v4' +import 'zod-openapi' + +export const DeviceIdSchema = z.string().min(1).meta({ + description: 'Unique identifier of the device.', + example: '5bdbe70f55d0ad001a04edc9', +}) + +export const DevicePathParamsSchema = z.object({ + deviceId: DeviceIdSchema, +}) + +export const SensorIdSchema = z.string().min(1).meta({ + description: 'Unique identifier of the sensor.', + example: '60a13611a877b3001b8ffd59', +}) + +export const DeviceSensorPathParamsSchema = z.object({ + deviceId: DeviceIdSchema, + sensorId: SensorIdSchema, +}) + +export const IsoDateTimeSchema = z.iso.datetime().meta({ + description: 'ISO 8601 timestamp', + example: '2026-05-18T12:34:56.000Z', +}) + +export const IsoDateTimeToDateSchema = IsoDateTimeSchema.transform( + (value) => new Date(value), +) diff --git a/app/lib/openapi/schemas/device.ts b/app/lib/openapi/schemas/device.ts new file mode 100644 index 000000000..bceb16df8 --- /dev/null +++ b/app/lib/openapi/schemas/device.ts @@ -0,0 +1,271 @@ +import * as z from 'zod/v4' +import 'zod-openapi' + +export const GeoJsonPointSchema = z + .object({ + type: z.literal('Point'), + coordinates: z.tuple([z.number(), z.number()]).meta({ + description: '[longitude, latitude]', + example: [13.404954, 52.520008], + }), + }) + .meta({ + id: 'GeoJsonPoint', + description: 'GeoJSON Point geometry.', + }) + +export const ApiSensorSchema = z + .looseObject({ + _id: z.string().optional().meta({ + description: 'Sensor id in API format', + example: 'sensor123', + }), + id: z.string().optional().meta({ + description: 'Sensor id', + example: 'sensor123', + }), + title: z.string().nullable().optional().meta({ + description: 'Sensor title', + example: 'Temperature', + }), + unit: z.string().nullable().optional().meta({ + description: 'Sensor unit', + example: '°C', + }), + sensorType: z.string().nullable().optional().meta({ + description: 'Sensor type', + example: 'HDC1080', + }), + lastMeasurement: z + .object({ + createdAt: z.iso.datetime().optional().meta({ + example: '2023-01-01T00:00:00.000Z', + }), + value: z.union([z.string(), z.number()]).nullable().optional().meta({ + example: '25.13', + }), + }) + .nullable() + .optional(), + }) + .meta({ + id: 'ApiSensor', + description: 'Sensor belonging to a box/device.', + }) + +export const DeviceLocationInputSchema = z + .object({ + lat: z.number().meta({ + description: 'Latitude', + example: 51.9607, + }), + lng: z.number().meta({ + description: 'Longitude', + example: 7.6261, + }), + height: z.number().optional().meta({ + description: 'Optional height in meters', + example: 55, + }), + }) + .meta({ + id: 'DeviceLocationInput', + description: 'Device location update payload.', + }) + +export const DeviceSensorUpdateSchema = z + .object({ + id: z.string().optional().meta({ + description: 'Existing sensor id. Omit when creating a new sensor.', + example: '60a13611a877b3001b8ffd59', + }), + new: z.boolean().optional().meta({ + description: 'Whether this sensor should be created as new.', + example: true, + }), + title: z.string().optional().meta({ + example: 'PM10', + }), + unit: z.string().optional().meta({ + example: 'µg/m³', + }), + sensorType: z.string().optional().meta({ + example: 'SDS 011', + }), + }) + .meta({ + id: 'DeviceSensorUpdate', + description: 'Sensor update or creation payload.', + }) + +export type DeviceSensorUpdate = z.infer + +export const DeviceAddonsUpdateSchema = z + .object({ + add: z.string().optional().meta({ + description: + 'Addon to add to the device. The special value `feinstaub` may update the model and add PM sensors for compatible home models.', + example: 'feinstaub', + }), + }) + .meta({ + id: 'DeviceAddonsUpdate', + description: 'Legacy addon update payload.', + }) + +export const ApiDeviceSchema = z + .looseObject({ + _id: z.string().optional().meta({ + description: 'Unique device identifier in API format', + example: 'clx1234567890abcdef', + }), + id: z.string().optional().meta({ + description: 'Unique device identifier', + example: 'clx1234567890abcdef', + }), + name: z.string().optional().meta({ + description: 'Device name', + example: 'My Weather Station', + }), + description: z.string().nullable().optional().meta({ + description: 'Device description', + example: 'A weather monitoring station', + }), + image: z.string().nullable().optional().meta({ + description: 'Device image URL', + example: 'https://example.com/image.jpg', + }), + link: z.string().nullable().optional().meta({ + description: 'Device website link', + example: 'https://example.com', + }), + grouptag: z + .array(z.string()) + .optional() + .meta({ + description: 'Device group tags', + example: ['weather', 'outdoor'], + }), + exposure: z.string().nullable().optional().meta({ + description: 'Device exposure type', + example: 'outdoor', + }), + model: z.string().nullable().optional().meta({ + description: 'Device model', + example: 'homeV2Wifi', + }), + latitude: z.number().nullable().optional().meta({ + description: 'Device latitude', + example: 52.520008, + }), + longitude: z.number().nullable().optional().meta({ + description: 'Device longitude', + example: 13.404954, + }), + useAuth: z.boolean().optional().meta({ + description: 'Whether the device requires authentication', + example: true, + }), + public: z.boolean().optional().meta({ + description: 'Whether the device is public', + example: false, + }), + status: z.string().nullable().optional().meta({ + description: 'Device status', + example: 'inactive', + }), + createdAt: z.iso.datetime().optional().meta({ + description: 'Device creation timestamp', + example: '2024-01-15T10:30:00.000Z', + }), + updatedAt: z.iso.datetime().optional().meta({ + description: 'Device last update timestamp', + example: '2024-01-15T10:30:00.000Z', + }), + expiresAt: z.iso.datetime().nullable().optional().meta({ + description: 'Device expiration date', + example: '2024-12-31T23:59:59.000Z', + }), + userId: z.string().optional().meta({ + description: 'Owner user id', + example: 'user_123456', + }), + sensorWikiModel: z.string().nullable().optional().meta({ + description: 'Sensor Wiki model identifier', + example: 'homeV2Wifi', + }), + currentLocation: z + .object({ + type: z.literal('Point'), + coordinates: z.tuple([z.number(), z.number()]), + timestamp: z.iso.datetime().optional(), + }) + .optional() + .meta({ + description: 'Current location as GeoJSON Point-like object', + }), + lastMeasurementAt: z.iso.datetime().nullable().optional().meta({ + description: 'Last measurement timestamp', + example: '2023-01-01T00:00:00.000Z', + }), + loc: z.array(z.looseObject({})).optional().meta({ + description: 'Location history as GeoJSON features', + }), + integrations: z + .looseObject({}) + .optional() + .meta({ + description: 'Device integrations', + example: { + mqtt: { + enabled: false, + }, + }, + }), + sensors: z.array(ApiSensorSchema).optional().meta({ + description: 'Sensors belonging to this device', + }), + }) + .meta({ + id: 'ApiDevice', + description: + 'Device object returned by the API. Additional fields may be included depending on the database model and API transformation.', + }) + +export const DevicesResponseSchema = z.array(ApiDeviceSchema).meta({ + id: 'DevicesResponse', + description: 'List of devices.', +}) + +export const DevicesGeoJsonResponseSchema = z + .object({ + type: z.literal('FeatureCollection'), + features: z.array( + z.object({ + type: z.literal('Feature'), + geometry: GeoJsonPointSchema, + properties: ApiDeviceSchema, + }), + ), + }) + .meta({ + id: 'DevicesGeoJsonResponse', + description: + 'GeoJSON FeatureCollection of devices. Returned when `format=geojson`.', + example: { + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [13.404954, 52.520008], + }, + properties: { + id: 'clx1234567890abcdef', + name: 'My Weather Station', + }, + }, + ], + }, + }) diff --git a/app/lib/openapi/schemas/location.ts b/app/lib/openapi/schemas/location.ts new file mode 100644 index 000000000..d0c232da6 --- /dev/null +++ b/app/lib/openapi/schemas/location.ts @@ -0,0 +1,56 @@ +import z from 'zod/v4' + +export const CoordinatesSchema = z.tuple([z.number(), z.number()]).meta({ + description: '[longitude, latitude]', + example: [7.68123, 51.9123], +}) + +export const CoordinatesWithHeightSchema = z + .tuple([z.number(), z.number(), z.number().optional()]) + .meta({ + id: 'CoordinatesWithHeight', + description: '[longitude, latitude, height?]', + example: [7.68123, 51.9123, 66.6], + }) + +export const LocationObjectSchema = z + .object({ + lng: z.number().meta({ + description: 'Longitude', + example: 7.68123, + }), + lat: z.number().meta({ + description: 'Latitude', + example: 51.9123, + }), + height: z.number().optional().meta({ + description: 'Height above ground in meters.', + example: 66.6, + }), + }) + .meta({ + id: 'LocationObject', + description: + 'Location object with longitude, latitude, and optional height.', + }) + +export const LongitudeLatitudeLocationObjectSchema = z + .object({ + longitude: z.number().meta({ + description: 'Longitude', + example: 7.68123, + }), + latitude: z.number().meta({ + description: 'Latitude', + example: 51.9123, + }), + height: z.number().optional().meta({ + description: 'Height above ground in meters.', + example: 66.6, + }), + }) + .meta({ + id: 'LongitudeLatitudeLocationObject', + description: + 'Location object with longitude, latitude, and optional height.', + }) diff --git a/app/lib/openapi/schemas/measurement.ts b/app/lib/openapi/schemas/measurement.ts new file mode 100644 index 000000000..37b52ff5d --- /dev/null +++ b/app/lib/openapi/schemas/measurement.ts @@ -0,0 +1,56 @@ +import * as z from 'zod/v4' +import 'zod-openapi' + +export const MeasurementValueSchema = z.number().nullable().meta({ + description: 'Measured value', + example: 23.42, +}) + +export const MeasurementLocationIdSchema = z + .union([z.string(), z.number()]) + .nullable() + .meta({ + description: + 'ID of the location associated with the measurement. Depending on serialization this may be returned as a string or number.', + example: '123', + }) + +export const LastMeasurementSchema = z + .object({ + value: MeasurementValueSchema, + + createdAt: z.iso.datetime().meta({ + description: 'Timestamp of the latest measurement', + example: '2026-05-15T12:00:00.000Z', + }), + + sensorId: z.string().meta({ + description: 'ID of the sensor this measurement belongs to', + example: '60a13611a877b3001b8ffd59', + }), + }) + .meta({ + id: 'LastMeasurement', + description: 'Cached latest measurement for a sensor.', + }) + +export const MeasurementSchema = z + .object({ + sensorId: z.string().meta({ + description: 'ID of the sensor this measurement belongs to', + example: '60a13611a877b3001b8ffd59', + }), + + time: z.iso.datetime().meta({ + description: 'Measurement timestamp', + example: '2026-05-15T12:00:00.000Z', + }), + + value: MeasurementValueSchema, + + locationId: MeasurementLocationIdSchema, + }) + .meta({ + id: 'Measurement', + description: 'Measurement data.', + }) diff --git a/app/lib/openapi/schemas/sensor.ts b/app/lib/openapi/schemas/sensor.ts new file mode 100644 index 000000000..d9390d4b8 --- /dev/null +++ b/app/lib/openapi/schemas/sensor.ts @@ -0,0 +1,81 @@ +import * as z from 'zod/v4' +import 'zod-openapi' + +import { LastMeasurementSchema } from './measurement' + +export const UnknownJsonSchema = z.unknown().nullable().meta({ + description: 'Arbitrary JSON data', +}) + +export const SensorSchema = z + .object({ + id: z.string().meta({ + description: 'Sensor id', + example: '60a13611a877b3001b8ffd59', + }), + + title: z.string().nullable().meta({ + description: 'Sensor title', + example: 'Temperature', + }), + + unit: z.string().nullable().meta({ + description: 'Measurement unit', + example: '°C', + }), + + sensorType: z.string().nullable().meta({ + description: 'Sensor type', + example: 'HDC1080', + }), + + icon: z.string().nullable().meta({ + description: 'Sensor icon', + example: 'osem-thermometer', + }), + + status: z.string().nullable().meta({ + description: 'Sensor status', + example: 'active', + }), + + createdAt: z.iso.datetime().meta({ + description: 'Sensor creation timestamp', + example: '2026-05-15T12:00:00.000Z', + }), + + updatedAt: z.iso.datetime().meta({ + description: 'Sensor update timestamp', + example: '2026-05-15T12:00:00.000Z', + }), + + deviceId: z.string().meta({ + description: 'ID of the device this sensor belongs to', + example: '5bdbe70f55d0ad001a04edc9', + }), + + sensorWikiType: z.string().nullable().meta({ + example: 'temperature', + }), + + sensorWikiPhenomenon: z.string().nullable().meta({ + example: 'air_temperature', + }), + + sensorWikiUnit: z.string().nullable().meta({ + example: 'degree_celsius', + }), + + lastMeasurement: LastMeasurementSchema.nullable(), + + data: UnknownJsonSchema, + + order: z.number().int().nullable().meta({ + description: 'Display order of the sensor', + example: 0, + }), + }) + .meta({ + id: 'Sensor', + description: 'Sensor metadata.', + }) diff --git a/app/lib/openapi/schemas/user.ts b/app/lib/openapi/schemas/user.ts new file mode 100644 index 000000000..c88721d85 --- /dev/null +++ b/app/lib/openapi/schemas/user.ts @@ -0,0 +1,79 @@ +import * as z from 'zod/v4' +import 'zod-openapi' + +export const UserRoleSchema = z.enum(['admin', 'user']).meta({ + description: "User's role", + example: 'user', +}) + +export const UserLanguageSchema = z.enum(['de_DE', 'en_US']).meta({ + description: "User's preferred language", + example: 'en_US', +}) + +export const UserSchema = z + .object({ + id: z.string().meta({ + description: 'Unique user identifier', + example: 'user_123456', + }), + + name: z.string().meta({ + description: "User's display name", + example: 'John Doe', + }), + + email: z.email().meta({ + description: "User's email address", + example: 'user@example.com', + }), + + unconfirmedEmail: z.email().nullable().optional().meta({ + description: + 'Pending email address that has not been confirmed yet, if one exists.', + example: 'newemail@example.com', + }), + + role: UserRoleSchema.nullable().optional(), + + language: UserLanguageSchema.nullable().optional(), + + emailIsConfirmed: z.boolean().nullable().optional().meta({ + description: "Whether the user's email address is confirmed", + example: true, + }), + + createdAt: z.iso.datetime().meta({ + description: 'Account creation timestamp', + example: '2024-01-15T10:30:00.000Z', + }), + + updatedAt: z.iso.datetime().meta({ + description: 'Last account update timestamp', + example: '2024-01-20T14:45:00.000Z', + }), + + acceptedTosVersionId: z.string().nullable().optional().meta({ + description: 'ID of the Terms of Service version accepted by the user.', + example: 'tos_2024_01', + }), + + acceptedTosAt: z.iso.datetime().nullable().optional().meta({ + description: 'Timestamp when the user accepted the Terms of Service.', + example: '2024-01-15T10:30:00.000Z', + }), + }) + .meta({ + id: 'User', + description: 'User profile information.', + }) + +export const UserWithBoxesSchema = UserSchema.extend({ + boxes: z.array(z.string()).meta({ + description: 'A list of ids of the user’s devices', + example: ['60a13611a877b3001b8ffd59', '5bdbe70f55d0ad001a04edc9'], + }), +}).meta({ + id: 'UserWithBoxes', + description: 'User profile information including device ids.', +}) diff --git a/app/lib/request-parsing.ts b/app/lib/request-parsing.ts index 1a7eb1756..d1c7cd965 100644 --- a/app/lib/request-parsing.ts +++ b/app/lib/request-parsing.ts @@ -1,3 +1,9 @@ +import z from 'zod' +import { apiMessages } from './openapi/messages' +import { BearerTokenSchema } from './openapi/schemas/auth' +import { StandardResponse } from './responses' +import { RefreshAuthRequestSchema } from '~/routes/api.users.refresh-auth' + /** * Parses request data from either JSON or form data format. * Automatically detects the content type and parses accordingly. @@ -91,3 +97,86 @@ export async function parseRefreshTokenData(request: Request): Promise<{ token: data.token || '', } } + +export const parseRefreshAuthBody = async ( + request: Request, +): Promise | Response> => { + try { + const data = await parseRefreshTokenData(request) + const parsed = await RefreshAuthRequestSchema.safeParseAsync(data) + + if (!parsed.success) { + return StandardResponse.forbidden( + parsed.error.issues[0]?.message ?? apiMessages.tokenRequired, + ) + } + + return parsed.data + } catch (error) { + if (error instanceof Error && error.message.includes('Failed to parse')) { + return StandardResponse.forbidden( + `Invalid request format: ${error.message}`, + ) + } + + throw error + } +} + +export const parseBearerToken = (request: Request): string | Response => { + const authorizationHeader = request.headers.get('authorization') + const parsed = BearerTokenSchema.safeParse(authorizationHeader) + + if (!parsed.success) { + return StandardResponse.forbidden(apiMessages.refreshTokenInvalid) + } + + return parsed.data +} + +export const firstZodMessage = (error: z.ZodError) => + error.issues[0]?.message ?? 'Invalid request data' + +export async function parseJsonBody( + request: Request, + schema: TSchema, +): Promise | Response> { + let body: unknown + + try { + body = await request.json() + } catch { + return StandardResponse.badRequest('Invalid JSON in request body') + } + + const parsed = await schema.safeParseAsync(body) + + if (!parsed.success) { + return StandardResponse.badRequest(firstZodMessage(parsed.error)) + } + + return parsed.data +} + +export async function parseFormRequest( + request: Request, + schema: TSchema, +): Promise | Response> { + let formData: FormData + + try { + formData = await request.formData() + } catch { + return StandardResponse.badRequest('Could not parse form Data') + } + + const parsed = await schema.safeParseAsync( + Object.fromEntries(formData.entries()), + ) + + if (!parsed.success) { + return StandardResponse.badRequest(firstZodMessage(parsed.error)) + } + + return parsed.data +} diff --git a/app/lib/user-transform.ts b/app/lib/user-transform.ts new file mode 100644 index 000000000..a4b717610 --- /dev/null +++ b/app/lib/user-transform.ts @@ -0,0 +1,17 @@ +import { User } from '~/db/schema' + +export const transformUserToApiFormat = (user: User) => ({ + ...user, + createdAt: + user.createdAt instanceof Date + ? user.createdAt.toISOString() + : user.createdAt, + updatedAt: + user.updatedAt instanceof Date + ? user.updatedAt.toISOString() + : user.updatedAt, + acceptedTosAt: + user.acceptedTosAt instanceof Date + ? user.acceptedTosAt.toISOString() + : user.acceptedTosAt, +}) diff --git a/app/middleware/content-type-header.server.ts b/app/middleware/content-type-header.server.ts new file mode 100644 index 000000000..5561e2a02 --- /dev/null +++ b/app/middleware/content-type-header.server.ts @@ -0,0 +1,83 @@ +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 = + (methods?: string[]): MiddlewareFunction => + ({ request }) => { + const allowedMethods = methods?.map((method) => method.toUpperCase()) + + if ( + allowedMethods && + !allowedMethods.includes(request.method.toUpperCase()) + ) { + return + } + + 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 +} + +/** + * A middleware function responding with HTTP 415 Unsupported Media Type + * to requests that do not contain the Content-Type application/x-www-form-urlencoded + * or Content-Type multipart/form-data header. + */ +export const requestContentTypeForm: MiddlewareFunction = ({ + request, +}) => { + const contentType = request.headers.get('content-type') || '' + + if ( + !contentType.includes('application/x-www-form-urlencoded') && + !contentType.includes('multipart/form-data') + ) { + return StandardResponse.unsupportedMediaType( + 'Unsupported content-type. Try application/x-www-form-urlencoded', + ) + } +} + +export const requestContentTypeJsonOrForm = + (methods?: string[]): MiddlewareFunction => + ({ request }) => { + const allowedMethods = methods?.map((method) => method.toUpperCase()) + + if ( + allowedMethods && + !allowedMethods.includes(request.method.toUpperCase()) + ) { + return + } + const contentType = request.headers.get('content-type') || '' + + if ( + !contentType.includes('application/json') && + !contentType.includes('application/x-www-form-urlencoded') + ) { + return StandardResponse.unsupportedMediaType( + 'Unsupported content-type. Try application/json or application/x-www-form-urlencoded', + ) + } + } diff --git a/app/routes/api.boxes.$deviceId.$sensorId.measurements.ts b/app/routes/api.boxes.$deviceId.$sensorId.measurements.ts index 535bf59e0..4131aea6e 100644 --- a/app/routes/api.boxes.$deviceId.$sensorId.measurements.ts +++ b/app/routes/api.boxes.$deviceId.$sensorId.measurements.ts @@ -1,4 +1,3 @@ -import z from 'zod' import { type Route } from './+types/api.boxes.$deviceId.$sensorId.measurements' import { getUserDevices } from '~/db/models/device.server' import { @@ -9,6 +8,154 @@ import { import { getUserFromJwt } from '~/lib/jwt' import { StandardResponse } from '~/lib/responses' +import * as z from 'zod/v4' +import { type ZodOpenApiPathItemObject } from 'zod-openapi' + +import { + DeviceSensorPathParamsSchema, + IsoDateTimeToDateSchema, +} from '~/lib/openapi/schemas/common' + +import { + ForbiddenErrorSchema, + InternalServerErrorSchema, + MethodNotAllowedErrorSchema, + NotFoundErrorSchema, + createBadRequestErrorSchema, + messageResponse, +} from '~/lib/openapi/errors' + +import { + badRequestResponse, + forbiddenResponse, + internalServerErrorResponse, + methodNotAllowedResponse, + notFoundResponse, +} from '~/lib/openapi/errors' + +const DeleteSensorMeasurementsQueryParamsSchema = z + .object({ + 'from-date': IsoDateTimeToDateSchema.optional().meta({ + description: 'Beginning date of the measurement range to delete.', + example: '2026-05-13T12:00:00.000Z', + }), + + 'to-date': IsoDateTimeToDateSchema.optional().meta({ + description: 'End date of the measurement range to delete.', + example: '2026-05-15T12:00:00.000Z', + }), + + timestamps: z + .union([IsoDateTimeToDateSchema, z.array(IsoDateTimeToDateSchema)]) + .optional() + .transform((value) => { + if (value === undefined) return undefined + return Array.isArray(value) ? value : [value] + }) + .meta({ + description: 'One or more exact measurement timestamps to delete.', + example: ['2026-05-15T12:00:00.000Z'], + }), + + deleteAllMeasurements: z.enum(['true', 'false']).optional().meta({ + description: 'Set to `true` to delete all measurements of this sensor.', + example: 'true', + }), + }) + .meta({ + id: 'DeleteSensorMeasurementsQueryParams', + description: + 'Query parameters selecting which measurements should be deleted.', + }) +const DeleteSensorMeasurementsBadRequestErrorSchema = + createBadRequestErrorSchema({ + id: 'DeleteSensorMeasurementsBadRequestError', + description: + 'Bad request. This can happen for invalid path parameters, invalid dates, or invalid timestamp values.', + examples: [ + 'Invalid device id or sensor id specified', + 'from-date is invalid', + 'to-date is invalid', + 'timestamps contains invalid input', + ], + }) + +export const openapi: ZodOpenApiPathItemObject = { + delete: { + tags: ['Measurements'], + summary: 'Delete measurements of a sensor', + description: + 'Deletes measurements for the specified sensor. The measurements to delete are selected via query parameters. You can delete all measurements, delete specific timestamps, or delete a time range using `from-date` and `to-date`.', + operationId: 'deleteSensorMeasurements', + security: [{ bearerAuth: [] }], + + requestParams: { + path: DeviceSensorPathParamsSchema, + query: DeleteSensorMeasurementsQueryParamsSchema, + }, + + responses: { + 200: messageResponse('Measurements deleted successfully.'), + + 400: badRequestResponse( + DeleteSensorMeasurementsBadRequestErrorSchema, + 'Bad request. This can happen for invalid query parameters or invalid parameter combinations.', + ), + + 403: forbiddenResponse( + ForbiddenErrorSchema, + 'Invalid JWT authorization or the authenticated user is not allowed to delete data of the given device.', + ), + + 404: notFoundResponse( + NotFoundErrorSchema, + 'Sensor not found or not part of this device.', + ), + + 405: methodNotAllowedResponse( + MethodNotAllowedErrorSchema, + 'Method not allowed. Endpoint only supports DELETE.', + ), + + 500: internalServerErrorResponse( + InternalServerErrorSchema, + 'Internal server error.', + ), + }, + }, +} + +const parseQueryParams = async ( + request: Request, +): Promise> => { + const url = new URL(request.url) + const timestamps = url.searchParams.getAll('timestamps') + + const params = { + 'from-date': url.searchParams.get('from-date') ?? undefined, + 'to-date': url.searchParams.get('to-date') ?? undefined, + deleteAllMeasurements: + url.searchParams.get('deleteAllMeasurements') ?? undefined, + timestamps: + timestamps.length === 0 + ? undefined + : timestamps.length === 1 + ? timestamps[0] + : timestamps, + } + + const parseResult = + DeleteSensorMeasurementsQueryParamsSchema.safeParse(params) + + if (!parseResult.success) { + const firstError = parseResult.error.issues[0] + const message = firstError.message || 'Invalid query parameters' + throw StandardResponse.badRequest(message) + } + + return parseResult.data +} + export async function action({ request, params }: Route.ActionArgs) { try { const { deviceId, sensorId } = params @@ -43,7 +190,7 @@ export async function action({ request, params }: Route.ActionArgs) { const parsedParams = await parseQueryParams(request) let count = 0 - if (parsedParams.deleteAllMeasurements) + if (parsedParams.deleteAllMeasurements === 'true') count = (await deleteMeasurementsForSensor(sensorId)).count else if (parsedParams.timestamps) count = ( @@ -74,79 +221,3 @@ export async function action({ request, params }: Route.ActionArgs) { ) } } - -const DeleteQueryParams = z - .object({ - 'from-date': z - .string() - .transform((s) => new Date(s)) - .refine((d) => !isNaN(d.getTime()), { - message: 'from-date is invalid', - }) - .optional(), - 'to-date': z - .string() - .transform((s) => new Date(s)) - .refine((d) => !isNaN(d.getTime()), { - message: 'to-date is invalid', - }) - .optional(), - timestamps: z - .preprocess((val) => { - if (Array.isArray(val)) return val - else return [val] - }, z.array(z.string())) - .transform((a) => a.map((i) => new Date(i))) - .refine((a) => a.some((i) => !isNaN(i.getTime())), { - message: 'timestamps contains invalid input', - }) - .optional(), - deleteAllMeasurements: z.coerce.boolean().optional(), - }) - .superRefine((data, ctx) => { - const fromDateSet = data['from-date'] !== undefined - const toDateSet = data['to-date'] !== undefined - const timestampsSet = data.timestamps !== undefined - const deleteAllSet = data.deleteAllMeasurements !== undefined - - if (deleteAllSet && (timestampsSet || fromDateSet || toDateSet)) { - const paths: string[] = [] - if (timestampsSet) paths.push('timestamps') - if (fromDateSet) paths.push('from-date') - if (toDateSet) paths.push('to-date') - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'Parameter deleteAllMeasurements can only be used by itself', - path: paths, - }) - } else if (!deleteAllSet && timestampsSet && fromDateSet && toDateSet) - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: - 'Please specify only timestamps or a range with from-date and to-date', - path: ['timestamps', 'from-date', 'to-date'], - }) - else if (!deleteAllSet && !timestampsSet && !fromDateSet && !toDateSet) - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: - 'Please specify only timestamps or a range with from-date and to-date', - path: ['timestamps', 'from-date', 'to-date'], - }) - }) - -const parseQueryParams = async ( - request: Request, -): Promise> => { - const url = new URL(request.url) - const params: Record = Object.fromEntries(url.searchParams) - const parseResult = DeleteQueryParams.safeParse(params) - - if (!parseResult.success) { - const firstError = parseResult.error.issues[0] - const message = firstError.message || 'Invalid query parameters' - throw StandardResponse.badRequest(message) - } - - return parseResult.data -} diff --git a/app/routes/api.boxes.$deviceId.data.$sensorId.ts b/app/routes/api.boxes.$deviceId.data.$sensorId.ts index 1a3cb9158..087e362a5 100644 --- a/app/routes/api.boxes.$deviceId.data.$sensorId.ts +++ b/app/routes/api.boxes.$deviceId.data.$sensorId.ts @@ -9,138 +9,180 @@ import { } from '~/lib/outlier-transform' import { parseDateParam, parseEnumParam } from '~/lib/params' import { StandardResponse } from '~/lib/responses' +import { z } from 'zod' +import { type ZodOpenApiPathItemObject } from 'zod-openapi' +import { + badRequestResponse, + createBadRequestErrorSchema, + internalServerErrorResponse, + InternalServerErrorSchema, + NotFoundErrorSchema, + notFoundResponse, +} from '~/lib/openapi/errors' +import { DeviceSensorPathParamsSchema } from '~/lib/openapi/schemas/common' +import { + DateRangeQuerySchema, + DelimiterSchema, + OutputFormatSchema, + QueryDownloadSchema, + SeparatorSchema, +} from '~/lib/api-schemas/query' + +const SensorDataQueryParamsSchema = DateRangeQuerySchema.extend({ + outliers: z.enum(['replace', 'mark']).optional().meta({ + description: + 'Enables outlier calculation. `mark` adds `isOutlier` to each measurement. `replace` replaces outlier values according to the outlier transformation.', + example: 'mark', + }), + + 'outlier-window': z.coerce.number().int().min(1).max(50).default(15).meta({ + description: + 'Size of the moving window used as base to calculate outliers. Allowed values are numbers between 1 and 50.', + example: 15, + }), + + format: OutputFormatSchema, + + download: QueryDownloadSchema, + + delimiter: DelimiterSchema.meta({ + description: + 'Only for CSV responses. Controls the CSV delimiter. Defaults to `comma`. Do not use together with `separator`.', + }), + + separator: SeparatorSchema, +}).meta({ + id: 'SensorDataQueryParams', + description: + 'Query parameters for retrieving measurements of a single sensor.', +}) + +const SensorMeasurementSchema = z + .object({ + sensorId: z.string().meta({ + description: 'ID of the sensor this measurement belongs to', + example: '6649b23072c4c40007105953', + }), + + time: z.iso.datetime().meta({ + description: 'Measurement timestamp', + example: '2025-11-06T23:59:57.189Z', + }), + + value: z.number().nullable().meta({ + description: 'Measured value', + example: 4.78, + }), + + locationId: z.union([z.string(), z.number()]).nullable().meta({ + description: + 'ID of the location associated with this measurement. Depending on serialization this may be returned as a string or number.', + example: '5752066', + }), -/** - * @openapi - * /boxes/{deviceId}/data/{sensorId}: - * get: - * tags: - * - Sensors - * summary: Get up to 10000 measurements from a sensor for a specific time frame - * description: Get up to 10000 measurements from a sensor for a specific time frame, parameters `from-date` and `to-date` are optional. If not set, the last 48 hours are used. The maximum time frame is 1 month. If `download=true` `Content-disposition` headers will be set. Allows for JSON or CSV format. - * parameters: - * - in: path - * name: deviceId - * required: true - * schema: - * type: string - * description: the ID of the device you are referring to - * - in: path - * name: sensorId - * required: true - * schema: - * type: string - * description: the ID of the sensor you are referring to - * - in: query - * name: outliers - * required: false - * schema: - * type: string - * enum: - * - replace - * - mark - * description: Specifying this parameter enables outlier calculation which adds a new field called `isOutlier` to the data. Possible values are "mark" and "replace". - * - in: query - * name: outlier-window - * required: false - * schema: - * type: integer - * minimum: 1 - * maximum: 50 - * default: 15 - * description: Size of moving window used as base to calculate the outliers. - * - in: query - * name: from-date - * required: false - * schema: - * type: string - * description: RFC3339Date - * format: date-time - * description: "Beginning date of measurement data (default: 48 hours ago from now)" - * - in: query - * name: to-date - * required: false - * schema: - * type: string - * descrption: TFC3339Date - * format: date-time - * description: "End date of measurement data (default: now)" - * - in: query - * name: format - * required: false - * schema: - * type: string - * enum: - * - json - * - csv - * default: json - * description: "Can be 'json' (default) or 'csv' (default: json)" - * - in: query - * name: download - * required: false - * schema: - * type: boolean - * description: if specified, the api will set the `content-disposition` header thus forcing browsers to download instead of displaying. Is always true for format csv. - * - in: query - * name: delimiter - * required: false - * schema: - * type: string - * enum: - * - comma - * - semicolon - * default: comma - * description: "Only for csv: the delimiter for csv. Possible values: `semicolon`, `comma`. Per default a comma is used. Alternatively you can use separator as parameter name." - * responses: - * 200: - * description: Success - * content: - * application/json: - * schema: - * type: array - * example: '[{"sensor_id":"6649b23072c4c40007105953","time":"2025-11-06 23:59:57.189+00","value":4.78,"location_id":"5752066"},{"sensor_id":"6649b23072c4c40007105953","time":"2025-11-06 23:57:06.03+00","value":4.13,"location_id":"5752066"}]' - * text/csv: - * example: "createdAt,value - * 2023-09-29T08:06:13.254Z,6.38 - * 2023-09-29T08:06:12.312Z,6.38 - * 2023-09-29T08:06:11.513Z,6.38 - * 2023-09-29T08:06:10.380Z,6.38 - * 2023-09-29T08:06:09.569Z,6.38 - * 2023-09-29T08:06:05.967Z,6.38" - * 400: - * description: Bad Request - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * message: - * type: string - * 404: - * description: Not found - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * message: - * type: string - * 500: - * description: Internal Server Error - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * message: - * type: string - */ + isOutlier: z.boolean().optional().meta({ + description: + 'Only present when outlier calculation is enabled via the `outliers` query parameter.', + example: false, + }), + }) + .meta({ + id: 'SensorMeasurement', + description: 'Measurement of a single sensor.', + }) + +const SensorMeasurementsJsonResponseSchema = z + .array(SensorMeasurementSchema) + .meta({ + id: 'SensorMeasurementsJsonResponse', + description: + 'Up to 10000 measurements from a sensor for the requested time frame.', + example: [ + { + sensorId: '6649b23072c4c40007105953', + time: '2025-11-06T23:59:57.189Z', + value: 4.78, + locationId: '5752066', + }, + { + sensorId: '6649b23072c4c40007105953', + time: '2025-11-06T23:57:06.030Z', + value: 4.13, + locationId: '5752066', + }, + ], + }) + +const SensorMeasurementsCsvResponseSchema = z.string().meta({ + id: 'SensorMeasurementsCsvResponse', + description: + 'CSV response with one measurement per row. The delimiter is controlled by the `delimiter` or `separator` query parameter.', + example: + 'createdAt,value\n2023-09-29T08:06:13.254Z,6.38\n2023-09-29T08:06:12.312Z,6.38', +}) + +const SensorMeasurementsBadRequestErrorSchema = createBadRequestErrorSchema({ + id: 'SensorMeasurementsBadRequestError', + description: + 'Bad request. This can happen for invalid dates, invalid enum parameters, or an invalid outlier window.', + examples: [ + 'Invalid from-date parameter.', + 'Invalid to-date parameter.', + 'Invalid format parameter.', + 'Illegal value for parameter outlier-window. Allowed values: numbers between 1 and 50', + ], +}) + +export const openapi: ZodOpenApiPathItemObject = { + get: { + tags: ['Sensors'], + summary: 'Get measurements from a sensor', + description: + 'Get up to 10000 measurements from a sensor for a specific time frame. `from-date` and `to-date` are optional; if omitted, the last 48 hours are used. The documented maximum time frame is one month. JSON and CSV response formats are supported. If `download=true`, a `Content-Disposition` header is set.', + operationId: 'getSensorMeasurements', + + requestParams: { + path: DeviceSensorPathParamsSchema, + query: SensorDataQueryParamsSchema, + }, + + responses: { + 200: { + description: 'Measurements returned successfully.', + headers: { + 'Content-Disposition': { + description: + 'Only present when `download=true` or when `format=csv`. Forces browsers to download the response.', + schema: { + type: 'string', + example: 'attachment; filename=6649b23072c4c40007105953.csv', + }, + }, + }, + content: { + 'application/json': { + schema: SensorMeasurementsJsonResponseSchema, + }, + 'text/csv': { + schema: SensorMeasurementsCsvResponseSchema, + }, + }, + }, + + 400: badRequestResponse( + SensorMeasurementsBadRequestErrorSchema, + 'Bad request. This can happen for invalid dates, invalid enum parameters, or an invalid outlier window.', + ), + + 404: notFoundResponse(NotFoundErrorSchema, 'Device or sensor not found.'), + + 500: internalServerErrorResponse( + InternalServerErrorSchema, + 'Internal server error.', + ), + }, + }, +} export const loader = async ({ request, @@ -176,10 +218,10 @@ export const loader = async ({ ? 'application/json; charset=utf-8' : 'text/csv; charset=utf-8', } - if (download) + if (download) { headers['Content-Disposition'] = `attachment; filename=${sensorId}.${format}` - + } const responseInit: ResponseInit = { status: 200, headers: headers, @@ -196,6 +238,30 @@ export const loader = async ({ } } +function collectDelimiterParam(url: URL): 'comma' | 'semicolon' | Response { + const delimiterParam = url.searchParams.get('delimiter') + const separatorParam = url.searchParams.get('separator') + + if (delimiterParam !== null && separatorParam !== null) { + return StandardResponse.badRequest( + 'Please specify only one of delimiter or separator.', + ) + } + + const paramName = delimiterParam !== null ? 'delimiter' : 'separator' + const value = delimiterParam ?? separatorParam + + if (value === null) return 'comma' + + if (value !== 'comma' && value !== 'semicolon') { + return StandardResponse.badRequest( + `Illegal value for parameter ${paramName}. Allowed values: comma, semicolon`, + ) + } + + return value +} + function collectParameters( request: Request, params: Params, @@ -209,7 +275,7 @@ function collectParameters( fromDate: Date toDate: Date format: string | null - download: boolean | null + download: boolean delimiter: string } { // deviceId is there for legacy reasons @@ -226,17 +292,20 @@ function collectParameters( if (outliers instanceof Response) return outliers const outlierWindowParam = url.searchParams.get('outlier-window') - let outlierWindow: number = 15 + let outlierWindow = 15 + if (outlierWindowParam !== null) { + outlierWindow = Number(outlierWindowParam) + if ( - Number.isNaN(outlierWindowParam) || - Number(outlierWindowParam) < 1 || - Number(outlierWindowParam) > 50 - ) + !Number.isInteger(outlierWindow) || + outlierWindow < 1 || + outlierWindow > 50 + ) { return StandardResponse.badRequest( 'Illegal value for parameter outlier-window. Allowed values: numbers between 1 and 50', ) - outlierWindow = Number(outlierWindowParam) + } } const fromDate = parseDateParam( @@ -253,26 +322,21 @@ function collectParameters( if (format instanceof Response) return format const downloadParam = parseEnumParam(url, 'download', ['true', 'false'], null) + if (downloadParam instanceof Response) return downloadParam - const download = downloadParam == null ? null : downloadParam === 'true' - const delimiter = parseEnumParam( - url, - 'delimiter', - ['comma', 'semicolon'], - 'comma', - ) + const delimiter = collectDelimiterParam(url) if (delimiter instanceof Response) return delimiter return { deviceId, sensorId, - outliers, + outliers: outliers as 'replace' | 'mark' | null, outlierWindow, fromDate, toDate, format, - download, + download: format === 'csv' || downloadParam === 'true', delimiter, } } diff --git a/app/routes/api.boxes.$deviceId.data.ts b/app/routes/api.boxes.$deviceId.data.ts index 371ad200d..4c822cd24 100644 --- a/app/routes/api.boxes.$deviceId.data.ts +++ b/app/routes/api.boxes.$deviceId.data.ts @@ -3,21 +3,252 @@ import { isValidServiceKey } from '~/db/models/integration.server' import { StandardResponse } from '~/lib/responses' import { postNewMeasurements } from '~/services/measurement-service.server' -/** - * @openapi - * /boxes/{deviceId}/data: - * post: - * tags: - * - Sensors - * summary: Post multiple new measurements in multiple formats to a box. Allows the use of csv, json array and json object notation. - * description: - * parameters: - * - in: header - * name: x-osem-device-api-key - * schema: - * type: string - * description: alternative HTTP header for authorizing your device if you cannot use the HTTP Authorization header - */ +import * as z from 'zod/v4' +import 'zod-openapi' +import { type ZodOpenApiPathItemObject } from 'zod-openapi' + +import { + BadRequestErrorSchema, + ConflictErrorSchema, + InternalServerErrorSchema, + NotFoundErrorSchema, + UnauthorizedErrorSchema, + UnprocessableContentErrorSchema, + UnsupportedMediaTypeErrorSchema, + badRequestResponse, + conflictResponse, + internalServerErrorResponse, + notFoundResponse, + unauthorizedResponse, + unprocessableContentResponse, + unsupportedMediaTypeResponse, +} from '~/lib/openapi/errors' + +import { DevicePathParamsSchema } from '~/lib/openapi/schemas/common' +import { + CoordinatesWithHeightSchema, + LocationObjectSchema, + LongitudeLatitudeLocationObjectSchema, +} from '~/lib/openapi/schemas/location' + +const PostBoxDataQueryParamsSchema = z + .object({ + luftdaten: z.string().optional().meta({ + description: + 'Presence flag. If present, the request body is decoded using the luftdaten decoder. The parameter value is ignored.', + example: '', + }), + + hackair: z.string().optional().meta({ + description: + 'Presence flag. If present, the request body is decoded using the hackAIR decoder. The parameter value is ignored.', + example: '', + }), + }) + .meta({ + id: 'PostBoxDataQueryParams', + description: 'Query parameters controlling legacy decoder selection.', + }) + +const PostBoxDataHeaderParamsSchema = z + .object({ + authorization: z.string().optional().meta({ + description: 'Device API key or bearer-style authorization value.', + example: 'Bearer device-api-key-or-token', + }), + + 'x-osem-device-api-key': z.string().optional().meta({ + description: + 'Alternative HTTP header for authorizing a device when the Authorization header cannot be used.', + example: 'device-api-key', + }), + }) + .meta({ + id: 'PostBoxDataHeaderParams', + description: 'Headers accepted when posting measurements to a box.', + }) + +export const MeasurementLocationSchema = z + .union([ + CoordinatesWithHeightSchema, + LocationObjectSchema, + LongitudeLatitudeLocationObjectSchema, + ]) + .meta({ + id: 'MeasurementLocation', + description: + 'Optional measurement location. Accepted as [longitude, latitude, height?], { lng, lat, height? }, or { longitude, latitude, height? }.', + }) + +const MeasurementJsonArrayItemSchema = z + .object({ + sensor_id: z.string().optional().meta({ + description: 'ID of the sensor this measurement belongs to.', + example: '5bdbe70f55d0ad001a04edc9', + }), + + sensor: z.string().optional().meta({ + description: 'Legacy alias for `sensor_id`.', + example: '5bdbe70f55d0ad001a04edc9', + }), + + value: z.union([z.number(), z.string()]).meta({ + description: 'Measurement value.', + example: 21.5, + }), + + createdAt: z.iso.datetime().optional().meta({ + description: 'Measurement timestamp. Defaults to current server time.', + example: '2026-05-22T12:00:00.000Z', + }), + + location: MeasurementLocationSchema.optional(), + }) + .meta({ + id: 'MeasurementJsonArrayItem', + description: 'Single measurement in JSON array notation.', + }) + +const MeasurementJsonObjectValueSchema = z.union([ + z.number(), + z.string(), + z.tuple([ + z.union([z.number(), z.string()]), + z.iso.datetime().optional(), + MeasurementLocationSchema.optional(), + ]), +]) + +const MeasurementJsonObjectSchema = z + .record(z.string(), MeasurementJsonObjectValueSchema) + .meta({ + id: 'MeasurementJsonObject', + description: + 'JSON object notation. Object keys are sensor IDs. Values can be a measurement value or [value, createdAt?, location?].', + example: { + '5bdbe70f55d0ad001a04edc9': 21.5, + '5bdbe70f55d0ad001a04edc8': [ + 42.1, + '2026-05-22T12:00:00.000Z', + [7.684, 51.972, 66.6], + ], + }, + }) + +const PostBoxDataJsonRequestSchema = z + .union([z.array(MeasurementJsonArrayItemSchema), MeasurementJsonObjectSchema]) + .meta({ + id: 'PostBoxDataJsonRequest', + description: + 'Measurements submitted as JSON array notation or JSON object notation.', + }) + +const PostBoxDataSuccessResponseSchema = z + .literal('Measurements saved in box') + .meta({ + id: 'PostBoxDataSuccessResponse', + description: 'Plain text success response.', + example: 'Measurements saved in box', + }) + +const PostBoxDataCsvRequestSchema = z.string().meta({ + id: 'PostBoxDataCsvRequest', + description: + 'CSV measurements, one measurement per line: sensorId,value,createdAt?,lng?,lat?,height?.', + example: + '5bdbe70f55d0ad001a04edc9,21.5,2026-05-22T12:00:00.000Z,7.684,51.972,66.6', +}) + +export const openapi: ZodOpenApiPathItemObject = { + post: { + tags: ['Sensors'], + summary: 'Post multiple new measurements to a box', + description: + 'Posts multiple measurements to a box. Supports JSON array notation, JSON object notation, CSV, luftdaten-compatible JSON, hackAIR-compatible JSON, and sbx binary formats.', + operationId: 'postBoxMeasurements', + + requestParams: { + path: DevicePathParamsSchema, + query: PostBoxDataQueryParamsSchema, + header: PostBoxDataHeaderParamsSchema, + }, + + requestBody: { + required: true, + content: { + 'application/json': { + schema: PostBoxDataJsonRequestSchema, + }, + + 'text/csv': { + schema: PostBoxDataCsvRequestSchema, + }, + + 'application/sbx-bytes': { + schema: { + type: 'string', + format: 'binary', + description: + 'Binary sbx-bytes payload. Each measurement is 16 bytes: 12-byte sensor id + 4-byte float32 value.', + }, + }, + + 'application/sbx-bytes-ts': { + schema: { + type: 'string', + format: 'binary', + description: + 'Binary sbx-bytes-ts payload. Each measurement is 20 bytes: 12-byte sensor id + 4-byte float32 value + 4-byte Unix timestamp.', + }, + }, + }, + }, + + responses: { + 201: { + description: 'Measurements saved successfully.', + content: { + 'text/plain': { + schema: PostBoxDataSuccessResponseSchema, + }, + }, + }, + + 400: badRequestResponse( + BadRequestErrorSchema, + 'Bad request. This can happen when the device id is missing or the request body is malformed.', + ), + + 401: unauthorizedResponse( + UnauthorizedErrorSchema, + 'Unauthorized. The device access token is missing or invalid.', + ), + + 404: notFoundResponse(NotFoundErrorSchema, 'Device not found.'), + + 409: conflictResponse( + ConflictErrorSchema, + 'Conflict. Archived devices are read-only.', + ), + + 415: unsupportedMediaTypeResponse( + UnsupportedMediaTypeErrorSchema, + 'Unsupported media type.', + ), + + 422: unprocessableContentResponse( + UnprocessableContentErrorSchema, + 'Unprocessable content. This can happen when decoding fails, a measurement references a sensor outside the box, a timestamp is too far in the future, or location coordinates are invalid.', + ), + + 500: internalServerErrorResponse( + InternalServerErrorSchema, + 'Internal server error.', + ), + }, + }, +} + export const action = async ({ request, params, diff --git a/app/routes/api.boxes.$deviceId.locations.ts b/app/routes/api.boxes.$deviceId.locations.ts index 1d87e3678..fd147805b 100644 --- a/app/routes/api.boxes.$deviceId.locations.ts +++ b/app/routes/api.boxes.$deviceId.locations.ts @@ -1,96 +1,167 @@ +import { z } from 'zod' import { type Params } from 'react-router' +import { type ZodOpenApiPathItemObject } from 'zod-openapi' import { type Route } from './+types/api.boxes.$deviceId.locations' import { getLocations } from '~/db/models/device.server' import { parseDateParam, parseEnumParam } from '~/lib/params' import { StandardResponse } from '~/lib/responses' -/** - * @openapi - * /boxes/{deviceId}/locations: - * get: - * tags: - * - Boxes - * summary: Get locations of a device - * description: Get all locations of the specified device ordered by date as an array of GeoJSON Points. - * If `format=geojson`, a GeoJSON linestring will be returned, with `properties.timestamps` - * being an array with the timestamp for each coordinate. - * parameters: - * - in: path - * name: deviceId - * required: true - * schema: - * type: string - * description: the ID of the device you are referring to - * - in: query - * name: from-date - * required: false - * schema: - * type: string - * description: RFC3339Date - * format: date-time - * description: "Beginning date of measurement data (default: 48 hours ago from now)" - * - in: query - * name: to-date - * required: false - * schema: - * type: string - * descrption: TFC3339Date - * format: date-time - * description: "End date of measurement data (default: now)" - * - in: query - * name: format - * required: false - * schema: - * type: string - * enum: - * - json - * - geojson - * default: json - * description: "Can be 'json' (default) or 'geojson' (default: json)" - * responses: - * 200: - * description: Success - * content: - * application/json: - * schema: - * type: array - * example: '[{ "coordinates": [7.68123, 51.9123], "type": "Point", "timestamp": "2017-07-27T12:00.000Z"},{ "coordinates": [7.68223, 51.9433, 66.6], "type": "Point", "timestamp": "2017-07-27T12:01.000Z"},{ "coordinates": [7.68323, 51.9423], "type": "Point", "timestamp": "2017-07-27T12:02.000Z"}]' - * application/geojson: - * example: '' - * 400: - * description: Bad Request - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * message: - * type: string - * 404: - * description: Not found - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * message: - * type: string - * 500: - * description: Internal Server Error - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * message: - * type: string - */ +import { + createBadRequestErrorSchema, + InternalServerErrorSchema, + NotFoundErrorSchema, + badRequestResponse, + internalServerErrorResponse, + notFoundResponse, +} from '~/lib/openapi/errors' + +import { apiMessages } from '~/lib/openapi/messages' +import { + DevicePathParamsSchema, + IsoDateTimeSchema, +} from '~/lib/openapi/schemas/common' +import { CoordinatesSchema } from '~/lib/openapi/schemas/location' +import { + DateRangeQuerySchema, + JsonGeoJsonFormatSchema, +} from '~/lib/api-schemas/query' + +const DeviceLocationsQueryParamsSchema = DateRangeQuerySchema.extend({ + format: JsonGeoJsonFormatSchema, +}) + +const PointLocationSchema = z + .object({ + coordinates: CoordinatesSchema, + type: z.literal('Point'), + timestamp: IsoDateTimeSchema.meta({ + description: 'Timestamp of the device location', + example: '2017-07-27T12:00:00.000Z', + }), + }) + .meta({ + id: 'DeviceLocationPoint', + description: 'Location of a device as GeoJSON Point-like object.', + example: { + coordinates: [7.68123, 51.9123], + type: 'Point', + timestamp: '2017-07-27T12:00:00.000Z', + }, + }) + +const JsonLocationsResponseSchema = z.array(PointLocationSchema).meta({ + id: 'DeviceLocationsJsonResponse', + description: + 'Device locations ordered by date as an array of GeoJSON Point-like objects.', + example: [ + { + coordinates: [7.68123, 51.9123], + type: 'Point', + timestamp: '2017-07-27T12:00:00.000Z', + }, + { + coordinates: [7.68223, 51.9433], + type: 'Point', + timestamp: '2017-07-27T12:01:00.000Z', + }, + { + coordinates: [7.68323, 51.9423], + type: 'Point', + timestamp: '2017-07-27T12:02:00.000Z', + }, + ], +}) + +const GeoJsonLineStringResponseSchema = z + .object({ + type: z.literal('Feature'), + geometry: z.object({ + type: z.literal('LineString'), + coordinates: z.array(CoordinatesSchema), + }), + properties: z.object({ + timestamps: z.array( + IsoDateTimeSchema.meta({ + example: '2017-07-27T12:00:00.000Z', + }), + ), + }), + }) + .meta({ + id: 'DeviceLocationsGeoJsonResponse', + description: + 'GeoJSON Feature containing a LineString. `properties.timestamps` contains one timestamp for each coordinate.', + example: { + type: 'Feature', + geometry: { + type: 'LineString', + coordinates: [ + [7.68123, 51.9123], + [7.68223, 51.9433], + [7.68323, 51.9423], + ], + }, + properties: { + timestamps: [ + '2017-07-27T12:00:00.000Z', + '2017-07-27T12:01:00.000Z', + '2017-07-27T12:02:00.000Z', + ], + }, + }, + }) + +const DeviceLocationsBadRequestErrorSchema = createBadRequestErrorSchema({ + id: 'DeviceLocationsBadRequestError', + description: + 'Bad request. This can happen for an invalid date parameter or invalid format parameter.', + examples: [ + 'Invalid from-date parameter.', + 'Invalid to-date parameter.', + 'Invalid format parameter.', + ], +}) + +export const openapi: ZodOpenApiPathItemObject = { + get: { + tags: ['Boxes'], + summary: 'Get locations of a device', + description: + 'Get all locations of the specified device ordered by date. By default, the response is an array of GeoJSON Point-like objects. If `format=geojson`, a GeoJSON LineString Feature is returned, with `properties.timestamps` containing one timestamp for each coordinate.', + operationId: 'getDeviceLocations', + + requestParams: { + path: DevicePathParamsSchema, + query: DeviceLocationsQueryParamsSchema, + }, + + responses: { + 200: { + description: 'Device locations returned successfully.', + content: { + 'application/json': { + schema: JsonLocationsResponseSchema, + }, + 'application/geo+json': { + schema: GeoJsonLineStringResponseSchema, + }, + }, + }, + + 400: badRequestResponse( + DeviceLocationsBadRequestErrorSchema, + 'Bad request. This can happen for an invalid device id, invalid date parameter, or invalid format parameter.', + ), + + 404: notFoundResponse(NotFoundErrorSchema, 'Device not found.'), + + 500: internalServerErrorResponse( + InternalServerErrorSchema, + 'Internal server error.', + ), + }, + }, +} export const loader = async ({ request, @@ -99,10 +170,14 @@ export const loader = async ({ try { const collected = collectParameters(request, params) if (collected instanceof Response) return collected + const { deviceId, fromDate, toDate, format } = collected const locations = await getLocations({ id: deviceId }, fromDate, toDate) - if (!locations) return StandardResponse.notFound('Device not found') + + if (!locations) { + return StandardResponse.notFound(apiMessages.deviceNotFound) + } const jsonLocations = locations.map((location) => { return { @@ -112,32 +187,32 @@ export const loader = async ({ } }) - let headers: HeadersInit = { - 'content-type': - format == 'json' - ? 'application/json; charset=utf-8' - : 'application/geo+json; charset=utf-8', - } - const responseInit: ResponseInit = { status: 200, - headers: headers, + headers: { + 'content-type': + format === 'json' + ? 'application/json; charset=utf-8' + : 'application/geo+json; charset=utf-8', + }, } - if (format == 'json') return Response.json(jsonLocations, responseInit) - else { - const geoJsonLocations = { - type: 'Feature', - geometry: { - type: 'LineString', - coordinates: jsonLocations.map((location) => location.coordinates), - }, - properties: { - timestamps: jsonLocations.map((location) => location.timestamp), - }, - } - return Response.json(geoJsonLocations, responseInit) + if (format === 'json') { + return Response.json(jsonLocations, responseInit) } + + const geoJsonLocations = { + type: 'Feature', + geometry: { + type: 'LineString', + coordinates: jsonLocations.map((location) => location.coordinates), + }, + properties: { + timestamps: jsonLocations.map((location) => location.timestamp), + }, + } + + return Response.json(geoJsonLocations, responseInit) } catch (err) { console.warn(err) return StandardResponse.internalServerError() @@ -153,11 +228,13 @@ function collectParameters( deviceId: string fromDate: Date toDate: Date - format: string | null + format: 'json' | 'geojson' } { const deviceId = params.deviceId - if (deviceId === undefined) - return StandardResponse.badRequest('Invalid device id specified') + + if (deviceId === undefined) { + return StandardResponse.badRequest(apiMessages.deviceIdRequired) + } const url = new URL(request.url) @@ -166,18 +243,21 @@ function collectParameters( 'from-date', new Date(new Date().setDate(new Date().getDate() - 2)), ) + if (fromDate instanceof Response) return fromDate const toDate = parseDateParam(url, 'to-date', new Date()) + if (toDate instanceof Response) return toDate const format = parseEnumParam(url, 'format', ['json', 'geojson'], 'json') + if (format instanceof Response) return format return { deviceId, fromDate, toDate, - format, + format: format as 'json' | 'geojson', } } diff --git a/app/routes/api.boxes.$deviceId.script.ts b/app/routes/api.boxes.$deviceId.script.ts index a69ea08e1..62d2848b4 100644 --- a/app/routes/api.boxes.$deviceId.script.ts +++ b/app/routes/api.boxes.$deviceId.script.ts @@ -2,6 +2,208 @@ import SketchTemplater from '@sensebox/sketch-templater' import { type Route } from './+types/api.boxes.$deviceId.script' import { getDevice } from '~/db/models/device.server' +import * as z from 'zod/v4' +import 'zod-openapi' +import { ZodOpenApiPathItemObject } from 'zod-openapi' +import { DevicePathParamsSchema } from '~/lib/openapi/schemas/common' + +const SketchPortSchema = z.enum(['A', 'B', 'C']) + +const SketchOptionsSchema = z + .object({ + serialPort: z.enum(['Serial1', 'Serial2']).optional().meta({ + description: 'Serial port the SDS011 sensor is connected to.', + example: 'Serial1', + }), + + soilDigitalPort: SketchPortSchema.optional().meta({ + description: 'Digital port the SMT50 sensor is connected to.', + example: 'A', + }), + + soundMeterPort: SketchPortSchema.optional().meta({ + description: 'Digital port the sound level meter sensor is connected to.', + example: 'B', + }), + + windSpeedPort: SketchPortSchema.optional().meta({ + description: 'Digital port the wind speed sensor is connected to.', + example: 'C', + }), + + ssid: z.string().optional().meta({ + description: 'SSID of the Wi-Fi network.', + example: 'MyWiFi', + }), + + password: z.string().optional().meta({ + description: 'Password of the Wi-Fi network.', + example: 'super-secret-password', + format: 'password', + }), + + devEUI: z.string().optional().meta({ + description: 'devEUI of the TTN device.', + example: '70B3D57ED0000000', + }), + + appEUI: z.string().optional().meta({ + description: 'appEUI of the TTN application.', + example: '70B3D57ED0000000', + }), + + appKey: z.string().optional().meta({ + description: 'appKey of the TTN application.', + example: '00000000000000000000000000000000', + }), + + display_enabled: z.enum(['true', 'false']).optional().meta({ + description: 'Whether to include code for an attached OLED display.', + example: 'true', + }), + }) + .meta({ + id: 'SketchOptions', + description: + 'Optional sketch generation parameters. For GET requests these are passed as query parameters; for POST requests they are passed as form fields.', + }) + +const ArduinoSketchResponseSchema = z.string().meta({ + id: 'ArduinoSketchResponse', + description: 'Generated Arduino sketch as plain text.', + example: + '// Generated Arduino sketch\n#include \n\nvoid setup() {}\nvoid loop() {}', +}) + +const ScriptRouteErrorSchema = z + .object({ + code: z.string().meta({ + example: 'Bad Request', + }), + message: z.string().meta({ + example: 'Invalid device id specified', + }), + }) + .meta({ + id: 'ScriptRouteError', + description: 'Error response returned by the sketch generation endpoint.', + }) + +export const openapi: ZodOpenApiPathItemObject = { + get: { + tags: ['Boxes'], + summary: 'Download the Arduino script for a senseBox', + description: + 'Generates and returns an Arduino sketch for the specified senseBox. Optional sketch configuration values can be supplied as query parameters.', + operationId: 'getDeviceArduinoScript', + + requestParams: { + path: DevicePathParamsSchema, + query: SketchOptionsSchema, + }, + + responses: { + 200: { + description: 'Arduino sketch generated successfully.', + content: { + 'text/plain': { + schema: ArduinoSketchResponseSchema, + }, + }, + }, + + 400: { + description: 'Bad request. The device ID is missing or invalid.', + content: { + 'application/json': { + schema: ScriptRouteErrorSchema, + }, + }, + }, + + 404: { + description: 'Device not found.', + content: { + 'application/json': { + schema: ScriptRouteErrorSchema, + }, + }, + }, + + 500: { + description: 'Internal server error.', + content: { + 'application/json': { + schema: ScriptRouteErrorSchema, + }, + }, + }, + }, + }, + + post: { + tags: ['Boxes'], + summary: 'Generate the Arduino script for a senseBox from form data', + description: + 'Generates and returns an Arduino sketch for the specified senseBox. Optional sketch configuration values can be supplied as form fields.', + operationId: 'postDeviceArduinoScript', + + requestParams: { + path: DevicePathParamsSchema, + }, + + requestBody: { + required: false, + content: { + 'application/x-www-form-urlencoded': { + schema: SketchOptionsSchema, + }, + 'multipart/form-data': { + schema: SketchOptionsSchema, + }, + }, + }, + + responses: { + 200: { + description: 'Arduino sketch generated successfully.', + content: { + 'text/plain': { + schema: ArduinoSketchResponseSchema, + }, + }, + }, + + 400: { + description: 'Bad request. The device ID is missing or invalid.', + content: { + 'application/json': { + schema: ScriptRouteErrorSchema, + }, + }, + }, + + 404: { + description: 'Device not found.', + content: { + 'application/json': { + schema: ScriptRouteErrorSchema, + }, + }, + }, + + 500: { + description: 'Internal server error.', + content: { + 'application/json': { + schema: ScriptRouteErrorSchema, + }, + }, + }, + }, + }, +} + const cfg = { // The domain used in the generation of Arduino sketches ingress_domain: process.env.INGRESS_DOMAIN || 'ingress.opensensemap.org', diff --git a/app/routes/api.boxes.$deviceId.sensors.ts b/app/routes/api.boxes.$deviceId.sensors.ts index 5ac9ed131..ecb955376 100644 --- a/app/routes/api.boxes.$deviceId.sensors.ts +++ b/app/routes/api.boxes.$deviceId.sensors.ts @@ -1,34 +1,104 @@ import { type Route } from './+types/api.boxes.$deviceId.sensors' import { StandardResponse } from '~/lib/responses' import { getLatestMeasurements } from '~/services/measurement-service.server' +import { z } from 'zod' +import { type ZodOpenApiPathItemObject } from 'zod-openapi' +import { SensorSchema } from '~/lib/openapi/schemas/sensor' +import { MeasurementSchema } from '~/lib/openapi/schemas/measurement' +import { + badRequestResponse, + createBadRequestErrorSchema, + internalServerErrorResponse, + InternalServerErrorSchema, + NotFoundErrorSchema, + notFoundResponse, +} from '~/lib/openapi/errors' +import { DevicePathParamsSchema } from '~/lib/openapi/schemas/common' +import { apiMessages } from '~/lib/openapi/messages' -/** - * @openapi - * /boxes/{deviceId}/sensors: - * get: - * tags: - * - Sensors - * summary: Get the latest measurements of all sensors of the specified device. - * parameters: - * - in: path - * name: deviceId - * required: true - * schema: - * type: string - * description: the ID of the device you are referring to - * - in: query - * name: count - * required: false - * schema: - * type: integer - * minimum: 1 - * maximum: 100 - * description: Number of measurements to be retrieved for every sensor - * responses: - * 200: - * description: Success - * content: - */ +const messages = { + invalidCount: + 'Illegal value for parameter count. allowed values: numbers from 1 to 100', +} + +const DeviceSensorsQueryParamsSchema = z.object({ + count: z.coerce.number().int().min(1).max(100).optional().meta({ + description: + 'Number of measurements to retrieve for every sensor. Allowed values are numbers from 1 to 100.', + example: 5, + }), +}) + +const SensorWithLatestMeasurementSchema = SensorSchema.and( + MeasurementSchema, +).meta({ + id: 'SensorWithLatestMeasurement', + description: 'Sensor metadata combined with its latest measurement fields.', +}) + +const DeviceWithSensorsSchema = z + .object({ + id: z.string().meta({ + description: 'Device id', + example: '5bdbe70f55d0ad001a04edc9', + }), + + sensors: z.array(SensorWithLatestMeasurementSchema).meta({ + description: + 'Sensors of this device, each enriched with latest measurement data.', + }), + }) + .meta({ + id: 'DeviceWithSensors', + description: 'Device including sensors with their latest measurement data.', + }) + +const DeviceSensorsBadRequestErrorSchema = createBadRequestErrorSchema({ + id: 'DeviceSensorsBadRequestError', + description: + 'Bad request. This can happen when the `count` query parameter is invalid.', + examples: [ + 'Illegal value for parameter count. allowed values: numbers from 1 to 100', + ], +}) + +export const openapi: ZodOpenApiPathItemObject = { + get: { + tags: ['Sensors'], + summary: 'Get latest measurements of all sensors of a device', + description: + 'Returns the specified device with its sensors. Each sensor is enriched with latest measurement data. The optional `count` query parameter controls how many measurements are retrieved per sensor, depending on service behavior.', + operationId: 'getDeviceSensorMeasurements', + + requestParams: { + path: DevicePathParamsSchema, + query: DeviceSensorsQueryParamsSchema, + }, + + responses: { + 200: { + description: 'Device sensors returned successfully.', + content: { + 'application/json': { + schema: DeviceWithSensorsSchema, + }, + }, + }, + + 400: badRequestResponse( + DeviceSensorsBadRequestErrorSchema, + 'Bad request. This can happen when the `count` query parameter is invalid.', + ), + + 404: notFoundResponse(NotFoundErrorSchema, 'Device not found.'), + + 500: internalServerErrorResponse( + InternalServerErrorSchema, + 'Internal server error.', + ), + }, + }, +} export const loader = async ({ request, @@ -36,22 +106,30 @@ export const loader = async ({ }: Route.LoaderArgs): Promise => { try { const deviceId = params.deviceId - if (deviceId === undefined) - return StandardResponse.badRequest('Invalid device id specified') + + if (deviceId === undefined) { + return StandardResponse.badRequest(apiMessages.deviceIdRequired) + } const url = new URL(request.url) const countParam = url.searchParams.get('count') let count: undefined | number = undefined - if (countParam !== null && Number.isNaN(countParam)) - return StandardResponse.badRequest( - 'Illegal value for parameter count. allowed values: numbers', - ) - count = countParam === null ? undefined : Number(countParam) + if (countParam !== null) { + count = Number(countParam) + + if (!Number.isInteger(count) || count < 1 || count > 100) { + return StandardResponse.badRequest(messages.invalidCount) + } + } const meas = await getLatestMeasurements(deviceId, count) + if (!meas) { + return StandardResponse.notFound(apiMessages.deviceNotFound) + } + return StandardResponse.ok(meas) } catch (err) { console.warn(err) diff --git a/app/routes/api.boxes.$deviceId.ts b/app/routes/api.boxes.$deviceId.ts index 151b2e9f5..499b7a257 100644 --- a/app/routes/api.boxes.$deviceId.ts +++ b/app/routes/api.boxes.$deviceId.ts @@ -7,70 +7,311 @@ import { } from '~/db/models/device.server' import { type Device, type User } from '~/db/schema' import { transformDeviceToApiFormat } from '~/lib/device-transform' -import { getUserFromJwt } from '~/lib/jwt' +import { getAuthenticatedUser } from '~/lib/jwt' import { StandardResponse } from '~/lib/responses' import { deleteDevice } from '~/services/device-service.server' +import { z } from 'zod' +import { type ZodOpenApiPathItemObject } from 'zod-openapi' +import { + BadRequestErrorSchema, + badRequestResponse, + createBadRequestErrorSchema, + ForbiddenErrorSchema, + forbiddenResponse, + internalServerErrorResponse, + InternalServerErrorSchema, + NotFoundErrorSchema, + notFoundResponse, + UnauthorizedErrorSchema, + unauthorizedResponse, +} from '~/lib/openapi/errors' +import { DevicePathParamsSchema } from '~/lib/openapi/schemas/common' +import { apiMessages } from '~/lib/openapi/messages' +import { + DeviceLocationInputSchema, + DeviceSensorUpdateSchema, + DeviceAddonsUpdateSchema, + ApiDeviceSchema, +} from '~/lib/openapi/schemas/device' +import { ExposureSchema } from '~/lib/api-schemas/query' +import { + requestContentTypeJson, + responseContentTypeJson, +} from '~/middleware/content-type-header.server' +import { parseJsonBody } from '~/lib/request-parsing' + +const messages = { + conflictingSensorsAndAddons: + 'sensors and addons can not appear in the same request.', +} + +const UpdateDeviceRequestSchema = z + .object({ + name: z.string().optional().meta({ + description: 'Device name', + example: 'My device', + }), + + exposure: ExposureSchema, + + description: z.string().optional().meta({ + description: 'Device description', + example: 'Sensor device on my balcony', + }), + + image: z.string().optional().meta({ + description: 'Device image URL or image value', + }), + + deleteImage: z.boolean().optional().meta({ + description: 'If true, the device image is removed.', + example: true, + }), + + model: z.string().optional().meta({ + description: 'Device model', + example: 'homeWifi', + }), + + useAuth: z.boolean().optional().meta({ + description: 'Whether device API-key authentication is enabled', + example: true, + }), + + weblink: z.string().optional().meta({ + description: 'Web link for the device.', + example: 'https://example.com', + }), + + location: DeviceLocationInputSchema.optional(), + + grouptag: z + .array(z.string()) + .optional() + .meta({ + description: 'Group tags assigned to the device', + example: ['school', 'feinstaub'], + }), + + sensors: z.array(DeviceSensorUpdateSchema).optional().meta({ + description: + 'Sensors to update or create. Must not be used together with `addons.add`.', + }), + + addons: DeviceAddonsUpdateSchema.optional(), + }) + .superRefine((body, ctx) => { + if (body.sensors && body.addons?.add) { + ctx.addIssue({ + code: 'custom', + path: ['sensors'], + message: messages.conflictingSensorsAndAddons, + }) + } + }) + .meta({ + id: 'UpdateDeviceRequest', + description: 'Device update payload.', + }) + +const DeleteDeviceRequestSchema = z + .object({ + password: z + .string() + .min(1, { + error: apiMessages.passwordRequired, + }) + .meta({ + description: 'Current user password required to delete the device', + example: 'myCurrentPassword123', + format: 'password', + }), + }) + .meta({ + id: 'DeleteDeviceRequest', + description: 'Device deletion confirmation payload.', + }) + +const DeviceBadRequestErrorSchema = createBadRequestErrorSchema({ + id: 'DeviceBadRequestError', + description: + 'Bad request. This can happen when the device id is missing, the deletion password is missing, or the update payload contains conflicting fields.', + examples: [ + apiMessages.deviceIdRequired, + apiMessages.passwordRequired, + messages.conflictingSensorsAndAddons, + ], +}) + +export const openapi: ZodOpenApiPathItemObject = { + get: { + tags: ['Boxes'], + summary: 'Get device by ID', + description: 'Retrieve a single device by its unique identifier.', + operationId: 'getDeviceById', + + requestParams: { + path: DevicePathParamsSchema, + }, + + responses: { + 200: { + description: 'Device retrieved successfully.', + content: { + 'application/json': { + schema: ApiDeviceSchema, + }, + }, + }, + + 400: badRequestResponse( + BadRequestErrorSchema, + 'Bad request. The device ID path parameter is missing or malformed.', + ), + + 404: notFoundResponse(NotFoundErrorSchema, 'Device not found.'), + + 500: internalServerErrorResponse( + InternalServerErrorSchema, + 'Internal server error.', + ), + }, + }, + + put: { + tags: ['Boxes'], + summary: 'Update device', + description: 'Updates a device. Requires JWT authorization.', + operationId: 'updateDevice', + security: [{ bearerAuth: [] }], + + requestParams: { + path: DevicePathParamsSchema, + }, + + requestBody: { + required: true, + content: { + 'application/json': { + schema: UpdateDeviceRequestSchema, + }, + }, + }, + + responses: { + 200: { + description: 'Device updated successfully.', + content: { + 'application/json': { + schema: ApiDeviceSchema, + }, + }, + }, + + 400: badRequestResponse( + DeviceBadRequestErrorSchema, + 'Bad request. This can happen for conflicting parameters or validation errors.', + ), + + 403: forbiddenResponse( + ForbiddenErrorSchema, + 'Invalid or missing JWT authorization.', + ), + + 404: notFoundResponse(NotFoundErrorSchema, 'Device not found.'), + + 500: internalServerErrorResponse( + InternalServerErrorSchema, + 'Internal server error.', + ), + }, + }, + + delete: { + tags: ['Boxes'], + summary: 'Delete device', + description: + 'Deletes a device. Requires JWT authorization and the current user password.', + operationId: 'deleteDevice', + security: [{ bearerAuth: [] }], + + requestParams: { + path: DevicePathParamsSchema, + }, + + requestBody: { + required: true, + content: { + 'application/json': { + schema: DeleteDeviceRequestSchema, + }, + }, + }, + + responses: { + 200: { + description: 'Device deleted successfully.', + content: { + 'application/json': { + schema: z.null().meta({ + description: 'JSON null response indicating successful deletion.', + }), + }, + }, + }, + + 400: badRequestResponse( + DeviceBadRequestErrorSchema, + 'Bad request. This can happen when the password is missing.', + ), + + 401: unauthorizedResponse( + UnauthorizedErrorSchema, + 'Unauthorized. The provided password is incorrect.', + ), + + 403: forbiddenResponse( + ForbiddenErrorSchema, + 'Invalid or missing JWT authorization.', + ), + + 404: notFoundResponse(NotFoundErrorSchema, 'Device not found.'), + + 500: internalServerErrorResponse( + InternalServerErrorSchema, + 'Internal server error.', + ), + }, + }, +} + +const parsePathParams = (params: Route.LoaderArgs['params']) => { + const parsed = DevicePathParamsSchema.safeParse(params) + + if (!parsed.success) { + return StandardResponse.badRequest(apiMessages.deviceIdRequired) + } + + return parsed.data +} + +const okDeviceResponse = async (device: unknown) => { + const apiDevice = transformDeviceToApiFormat(device as any) + const parsed = await ApiDeviceSchema.safeParseAsync(apiDevice) + + if (!parsed.success) { + console.warn(parsed.error) + return StandardResponse.internalServerError() + } + + return StandardResponse.ok(parsed.data) +} + +export const middleware: Route.MiddlewareFunction[] = [ + requestContentTypeJson(['PUT', 'DELETE']), + responseContentTypeJson, +] -/** - * @openapi - * /api/device/{deviceId}: - * get: - * summary: Get device by ID - * description: Retrieve a single device by their unique identifier - * tags: - * - Device - * parameters: - * - in: path - * name: id - * required: true - * description: Unique identifier of the user - * schema: - * type: string - * example: "12345" - * responses: - * 200: - * description: Device retrieved successfully - * content: - * application/json: - * schema: - * type: object - * properties: - * id: - * type: string - * example: "12345" - * name: - * type: string - * example: "John Doe" - * email: - * type: string - * example: "john.doe@example.com" - * createdAt: - * type: string - * format: date-time - * example: "2023-01-15T10:30:00Z" - * 404: - * description: Device not found - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: "Device not found" - * 400: - * description: Device ID is required - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: "Device ID is required." - * 500: - * description: Internal server error - */ export async function loader({ params }: Route.LoaderArgs) { const { deviceId } = params @@ -81,7 +322,7 @@ export async function loader({ params }: Route.LoaderArgs) { if (!device) return StandardResponse.notFound('Device not found.') - return StandardResponse.ok(device) + return await okDeviceResponse(device) } catch (error) { console.error('Error fetching box:', error) @@ -102,32 +343,21 @@ export async function loader({ params }: Route.LoaderArgs) { } export async function action({ request, params }: Route.ActionArgs) { - const { deviceId } = params - - if (!deviceId) { - return Response.json({ error: 'Device ID is required.' }, { status: 400 }) - } - - const jwtResponse = await getUserFromJwt(request) + const parsedParams = parsePathParams(params) + if (parsedParams instanceof Response) return parsedParams - if (typeof jwtResponse === 'string') { - return Response.json( - { - code: 'Forbidden', - message: - 'Invalid JWT authorization. Please sign in to obtain a new JWT.', - }, - { status: 403 }, - ) - } + const user = await getAuthenticatedUser(request) + if (user instanceof Response) return user switch (request.method) { case 'PUT': - return await put(request, jwtResponse, deviceId) + return await put(request, user, parsedParams.deviceId) + case 'DELETE': - return await del(request, jwtResponse, deviceId) + return await del(request, user, parsedParams.deviceId) + default: - return Response.json({ message: 'Method Not Allowed' }, { status: 405 }) + return StandardResponse.methodNotAllowed('Method Not Allowed') } } @@ -258,22 +488,25 @@ async function put(request: Request, user: any, deviceId: string) { } async function del(request: Request, user: User, deviceId: string) { - const device = (await getDevice({ id: deviceId })) as unknown as Device + const device = await getDevice({ id: deviceId }) - if (!device) throw StandardResponse.notFound('Device not found') - - const body = await request.json() + if (!device) { + return StandardResponse.notFound('Device not found') + } - if (!body.password) - throw StandardResponse.badRequest( - 'Password is required for device deletion', - ) + const parsedBody = await parseJsonBody(request, DeleteDeviceRequestSchema) + if (parsedBody instanceof Response) return parsedBody try { - const deleted = await deleteDevice(user, device, body.password) + const deleted = await deleteDevice( + user, + device as Device, + parsedBody.password, + ) - if (deleted === 'unauthorized') + if (deleted === 'unauthorized') { return StandardResponse.unauthorized('Password incorrect') + } return StandardResponse.ok(null) } catch (err) { diff --git a/app/routes/api.boxes.claim.ts b/app/routes/api.boxes.claim.ts new file mode 100644 index 000000000..9762254ef --- /dev/null +++ b/app/routes/api.boxes.claim.ts @@ -0,0 +1,209 @@ +import { type Route } from './+types/api.boxes.claim' +import { getUserFromJwt } from '~/lib/jwt' +import { StandardResponse } from '~/lib/responses' +import { claimBox } from '~/services/transfer-service.server' + +import * as z from 'zod/v4' +import 'zod-openapi' +import { type ZodOpenApiPathItemObject } from 'zod-openapi' + +import { + ForbiddenErrorSchema, + InternalServerErrorSchema, + MethodNotAllowedErrorSchema, + UnsupportedMediaTypeErrorSchema, + createBadRequestErrorSchema, + createGoneErrorSchema, + createNotFoundErrorSchema, +} from '~/lib/openapi/errors' + +import { + badRequestResponse, + forbiddenResponse, + goneResponse, + internalServerErrorResponse, + methodNotAllowedResponse, + notFoundResponse, + unsupportedMediaTypeResponse, +} from '~/lib/openapi/errors' + +const ClaimBoxRequestSchema = z + .object({ + token: z + .string() + .trim() + .min(1, { + error: 'token is required', + }) + .meta({ + description: 'Transfer token used to claim the device.', + example: 'clm_01jv7c9x8n0example', + }), + }) + .meta({ + id: 'ClaimBoxRequest', + description: 'Payload for claiming a device marked for transfer.', + }) + +const ClaimBoxResultSchema = z + .object({ + boxId: z.string().meta({ + description: 'ID of the claimed device.', + example: '5bdbe70f55d0ad001a04edc9', + }), + }) + .meta({ + id: 'ClaimBoxResult', + description: 'Result of a successful device claim.', + }) + +const ClaimBoxResponseSchema = z + .object({ + code: z.literal('Ok').default('Ok'), + message: z + .literal('Device successfully claimed!') + .default('Device successfully claimed!'), + data: ClaimBoxResultSchema, + }) + .meta({ + id: 'ClaimBoxResponse', + description: 'Device claim success response.', + }) + +const ClaimBoxBadRequestErrorSchema = createBadRequestErrorSchema({ + id: 'ClaimBoxBadRequestError', + description: + 'Bad request. This can happen when the transfer token is missing, invalid, or belongs to a device the user already owns.', + examples: ['token is required', 'You already own this device'], +}) + +const ClaimBoxGoneErrorSchema = createGoneErrorSchema({ + id: 'ClaimBoxGoneError', + description: 'Returned when the transfer token is invalid or expired.', + examples: ['Invalid or expired transfer token', 'Transfer token has expired'], +}) + +const ClaimBoxNotFoundErrorSchema = createNotFoundErrorSchema({ + id: 'ClaimBoxNotFoundError', + description: + 'Returned when the device referenced by the transfer claim no longer exists.', + messageSchema: z.literal('Device not found'), +}) + +export const openapi: ZodOpenApiPathItemObject = { + post: { + tags: ['Boxes'], + summary: 'Claim a transferred device', + description: + 'Claims a device that has been marked for transfer. Requires a valid JWT bearer token and a valid transfer token in the JSON request body.', + operationId: 'claimBox', + security: [{ bearerAuth: [] }], + + requestBody: { + required: true, + content: { + 'application/json': { + schema: ClaimBoxRequestSchema, + }, + }, + }, + + responses: { + 200: { + description: 'Device successfully claimed.', + content: { + 'application/json': { + schema: ClaimBoxResponseSchema, + }, + }, + }, + + 400: badRequestResponse( + ClaimBoxBadRequestErrorSchema, + 'Bad request. The transfer token is missing, invalid, or the user already owns the device.', + ), + + 403: forbiddenResponse( + ForbiddenErrorSchema, + 'Invalid or missing JWT authorization.', + ), + + 404: notFoundResponse(ClaimBoxNotFoundErrorSchema, 'Device not found.'), + + 405: methodNotAllowedResponse( + MethodNotAllowedErrorSchema, + 'Method not allowed. Only POST is supported.', + ), + + 410: goneResponse( + ClaimBoxGoneErrorSchema, + 'The transfer token is invalid or expired.', + ), + + 415: unsupportedMediaTypeResponse( + UnsupportedMediaTypeErrorSchema, + 'Unsupported media type. Use application/json.', + ), + + 500: internalServerErrorResponse( + InternalServerErrorSchema, + 'Internal server error.', + ), + }, + }, +} + +export const action = async ({ request }: Route.ActionArgs) => { + if (request.method !== 'POST') + return StandardResponse.methodNotAllowed('Only POST allowed') + + const contentType = request.headers.get('content-type') + if (!contentType || !contentType.includes('application/json')) + return StandardResponse.unsupportedMediaType( + 'Unsupported content-type. Try application/json', + ) + + const jwtResponse = await getUserFromJwt(request) + + if (typeof jwtResponse === 'string') + return StandardResponse.forbidden('Invalid JWT. Please sign in') + + try { + const body = await request.json() + const { token } = body + + if (!token) return StandardResponse.badRequest('token is required') + + const result = await claimBox(jwtResponse.id, token) + + return StandardResponse.ok({ + message: 'Device successfully claimed!', + data: { + boxId: result.boxId, + }, + }) + } catch (err) { + console.error('Error claiming box:', err) + return handleClaimError(err) + } +} + +const handleClaimError = (err: unknown) => { + if (err instanceof Error) { + const message = err.message + + if (message.includes('expired') || message.includes('Invalid or expired')) + return StandardResponse.gone(message) + + if (message.includes('not found')) return StandardResponse.notFound(message) + + if ( + message.includes('required') || + message.includes('Invalid') || + message.includes('already own') + ) + return StandardResponse.badRequest(message) + } + + return StandardResponse.internalServerError() +} diff --git a/app/routes/api.boxes.data.ts b/app/routes/api.boxes.data.ts index 32447d57c..5102c823e 100644 --- a/app/routes/api.boxes.data.ts +++ b/app/routes/api.boxes.data.ts @@ -6,6 +6,253 @@ import { escapeCSVValue } from '~/lib/csv' import { StandardResponse } from '~/lib/responses' import { transformMeasurement } from '~/services/measurement-service.server' +import * as z from 'zod/v4' +import { type ZodOpenApiPathItemObject } from 'zod-openapi' + +import { + InternalServerErrorSchema, + NotFoundErrorSchema, + UnprocessableContentErrorSchema, + badRequestResponse, + createBadRequestErrorSchema, + internalServerErrorResponse, + notFoundResponse, + unprocessableContentResponse, +} from '~/lib/openapi/errors' + +import { + BboxSchema, + ColumnsBodySchema, + ColumnsQuerySchema, + DateRangeQuerySchema, + DelimiterSchema, + DownloadSchema, + ExposureSchema, + OutputFormatSchema, + QueryBboxSchema, + QueryDownloadSchema, +} from '~/lib/api-schemas/query' + +const BoxesDataRequestSchema = DateRangeQuerySchema.extend({ + phenomenon: z.string().optional(), + grouptag: z.string().optional(), + boxid: z.union([z.string(), z.array(z.string())]).optional(), + exposure: z.union([ExposureSchema, z.array(ExposureSchema)]).optional(), + bbox: BboxSchema, + format: OutputFormatSchema, + download: DownloadSchema, + delimiter: DelimiterSchema, + columns: ColumnsBodySchema, +}).meta({ + id: 'BoxesDataRequest', + description: + 'JSON body variant of the devices data query. Equivalent to the GET query parameters, but supports arrays naturally.', +}) + +const BoxesDataQueryParamsSchema = DateRangeQuerySchema.extend({ + phenomenon: z.string().optional().meta({ + description: 'Filter by sensor phenomenon/title.', + example: 'Temperatur', + }), + + grouptag: z.string().optional().meta({ + description: 'Filter devices by group tag.', + example: 'co2-campaign', + }), + + boxid: z.string().optional().meta({ + description: 'Filter by device ID.', + example: '5bdbe70f55d0ad001a04edc9', + }), + + exposure: ExposureSchema.optional(), + + bbox: QueryBboxSchema, + + format: OutputFormatSchema, + + download: QueryDownloadSchema, + + delimiter: DelimiterSchema, + + columns: ColumnsQuerySchema, +}).meta({ + id: 'BoxesDataQueryParams', + description: 'Query parameters for streaming measurements across devices.', +}) + +const BoxesDataJsonMeasurementSchema = z.record(z.string(), z.unknown()).meta({ + id: 'BoxesDataJsonMeasurement', + description: + 'Measurement row. The included properties depend on the requested `columns` parameter.', + example: { + createdAt: '2026-05-13T12:00:00.000Z', + value: 21.5, + boxId: '5bdbe70f55d0ad001a04edc9', + boxName: 'My device', + sensorId: '60a13611a877b3001b8ffd59', + phenomenon: 'Temperatur', + unit: '°C', + lat: 51.963, + lon: 7.628, + }, +}) + +const BoxesDataJsonResponseSchema = z + .array(BoxesDataJsonMeasurementSchema) + .meta({ + id: 'BoxesDataJsonResponse', + description: + 'Streamed JSON array of measurement rows. Each row contains the requested columns.', + }) + +const BoxesDataCsvResponseSchema = z.string().meta({ + id: 'BoxesDataCsvResponse', + description: + 'CSV measurement export. The first row contains the requested column names.', + example: + 'createdAt,value,boxId,boxName,sensorId,phenomenon,unit\n2026-05-13T12:00:00.000Z,21.5,5bdbe70f55d0ad001a04edc9,my Device,60a13611a877b3001b8ffd59,Temperatur,°C\n', +}) + +const BoxesDataBadRequestErrorSchema = createBadRequestErrorSchema({ + id: 'BoxesDataBadRequestError', + description: + 'Bad request. This can happen for invalid query/body parameters, invalid date values, invalid format, invalid delimiter, or invalid column configuration.', + examples: [ + 'Invalid query parameters', + 'Invalid from-date parameter.', + 'Invalid to-date parameter.', + 'Invalid format parameter.', + 'Invalid delimiter parameter.', + ], +}) + +export const openapi: ZodOpenApiPathItemObject = { + get: { + tags: ['Measurements'], + summary: 'Stream measurements across devices and sensors', + description: + 'Streams measurements matching the given filters. Results can be returned as JSON or CSV. This endpoint is useful for bulk data downloads and cross-device measurement queries.', + operationId: 'getBoxesData', + + requestParams: { + query: BoxesDataQueryParamsSchema, + }, + + responses: { + 200: { + description: 'Measurements streamed successfully.', + headers: { + 'Content-Disposition': { + description: + 'Present when `download=true`. Suggests a download filename.', + schema: { + type: 'string', + example: + 'attachment; filename=opensensemap_org-download-Temperatur-20260513_120000.csv', + }, + }, + }, + content: { + 'application/json': { + schema: BoxesDataJsonResponseSchema, + }, + 'text/csv': { + schema: BoxesDataCsvResponseSchema, + }, + }, + }, + + 400: badRequestResponse( + BoxesDataBadRequestErrorSchema, + 'Bad request. This can happen for invalid query parameters.', + ), + + 404: notFoundResponse( + NotFoundErrorSchema, + 'No matching devices or sensors found.', + ), + + 422: unprocessableContentResponse( + UnprocessableContentErrorSchema, + 'Unprocessable content. This can happen for invalid bounding box parameters.', + ), + + 500: internalServerErrorResponse( + InternalServerErrorSchema, + 'Internal server error.', + ), + }, + }, + + post: { + tags: ['Measurements'], + summary: + 'Stream measurements across devices and sensors using a JSON request body', + description: + 'POST variant of the devices data query. Parameters can be sent as a JSON request body. If the request body cannot be parsed as JSON, the server falls back to query parameters.', + operationId: 'postBoxesData', + + requestParams: { + query: BoxesDataQueryParamsSchema, + }, + + requestBody: { + required: false, + content: { + 'application/json': { + schema: BoxesDataRequestSchema, + }, + }, + }, + + responses: { + 200: { + description: 'Measurements streamed successfully.', + headers: { + 'Content-Disposition': { + description: + 'Present when `download=true`. Suggests a download filename.', + schema: { + type: 'string', + example: + 'attachment; filename=opensensemap_org-download-Temperatur-20260513_120000.csv', + }, + }, + }, + content: { + 'application/json': { + schema: BoxesDataJsonResponseSchema, + }, + 'text/csv': { + schema: BoxesDataCsvResponseSchema, + }, + }, + }, + + 400: badRequestResponse( + BoxesDataBadRequestErrorSchema, + 'Bad request. This can happen for invalid query or body parameters.', + ), + + 404: notFoundResponse( + NotFoundErrorSchema, + 'No matching devices or sensors found.', + ), + + 422: unprocessableContentResponse( + UnprocessableContentErrorSchema, + 'Unprocessable content. This can happen for invalid bounding box parameters.', + ), + + 500: internalServerErrorResponse( + InternalServerErrorSchema, + 'Internal server error.', + ), + }, + }, +} + function createDownloadFilename( date: Date, action: string, @@ -31,7 +278,9 @@ export async function loader({ request }: Route.LoaderArgs) { const headers = new Headers() headers.set( 'Content-Type', - params.format === 'csv' ? 'text/csv' : 'application/json', + params.format === 'csv' + ? 'text/csv; charset=utf-8' + : 'application/json; charset=utf-8', ) if (params.download) { const filename = createDownloadFilename( @@ -120,7 +369,12 @@ export async function loader({ request }: Route.LoaderArgs) { return new Response(stream, { headers }) } catch (err) { - if (err instanceof Response) throw err + if (err instanceof Response) { + if (err.status === 404) { + return StandardResponse.notFound('No matching sensors found') + } + return err + } return StandardResponse.internalServerError() } } diff --git a/app/routes/api.boxes.transfer.$deviceId.ts b/app/routes/api.boxes.transfer.$deviceId.ts new file mode 100644 index 000000000..ea02b944d --- /dev/null +++ b/app/routes/api.boxes.transfer.$deviceId.ts @@ -0,0 +1,311 @@ +import { type Route } from './+types/api.boxes.transfer.$deviceId' +import { getUserFromJwt } from '~/lib/jwt' +import { StandardResponse } from '~/lib/responses' +import { + getBoxTransfer, + updateBoxTransferExpiration, +} from '~/services/transfer-service.server' + +import * as z from 'zod/v4' +import 'zod-openapi' + +import { + BoxTransferClaimSchema, + BoxTransferTokenSchema, +} from '~/lib/openapi/schemas/claim' + +import { + ForbiddenErrorSchema, + InternalServerErrorSchema, + MethodNotAllowedErrorSchema, + NotFoundErrorSchema, + badRequestResponse, + createBadRequestErrorSchema, + forbiddenResponse, + internalServerErrorResponse, + methodNotAllowedResponse, + notFoundResponse, +} from '~/lib/openapi/errors' +import { DevicePathParamsSchema } from '~/lib/openapi/schemas/common' +import { ZodOpenApiPathItemObject } from 'zod-openapi' + +const UpdateBoxTransferRequestSchema = z + .object({ + token: BoxTransferTokenSchema, + + expiresAt: z.iso.datetime().meta({ + description: + 'New expiration date for the transfer token. Must be in the future.', + example: '2026-05-22T12:00:00.000Z', + }), + }) + .meta({ + id: 'UpdateBoxTransferRequest', + description: + 'Payload for updating the expiration date of a transfer token.', + }) + +const GetBoxTransferResponseSchema = z + .object({ + code: z.literal('Ok').default('Ok'), + data: BoxTransferClaimSchema, + }) + .meta({ + id: 'GetBoxTransferResponse', + description: 'Transfer information for a device.', + }) + +const UpdateBoxTransferResponseSchema = z + .object({ + code: z.literal('Ok').default('Ok'), + message: z + .literal('Transfer successfully updated') + .default('Transfer successfully updated'), + data: BoxTransferClaimSchema, + }) + .meta({ + id: 'UpdateBoxTransferResponse', + description: 'Updated transfer information for a device.', + }) + +const BoxTransferByDeviceBadRequestErrorSchema = createBadRequestErrorSchema({ + id: 'BoxTransferByDeviceBadRequestError', + description: + 'Bad request. This can happen when the device id, token, or expiration date is missing or invalid.', + examples: [ + 'Device ID is required', + 'token is required', + 'expiresAt is required', + 'Invalid transfer token', + 'Transfer token has expired', + 'Invalid expiration date format', + 'Expiration date must be in the future', + ], +}) + +export const openapi: ZodOpenApiPathItemObject = { + get: { + tags: ['Boxes'], + summary: 'Get transfer information for a device', + description: + 'Returns transfer information for a device. Requires JWT authorization. Only the owner of the box can view its transfer information.', + operationId: 'getBoxTransfer', + security: [{ bearerAuth: [] }], + + requestParams: { + path: DevicePathParamsSchema, + }, + + responses: { + 200: { + description: 'Transfer information returned successfully.', + content: { + 'application/json': { + schema: GetBoxTransferResponseSchema, + }, + }, + }, + + 400: badRequestResponse( + BoxTransferByDeviceBadRequestErrorSchema, + 'Bad request. The device ID path parameter is missing or invalid.', + ), + + 403: forbiddenResponse( + ForbiddenErrorSchema, + 'Invalid JWT authorization or the authenticated user is not allowed to view this transfer.', + ), + + 404: notFoundResponse(NotFoundErrorSchema, 'Box or transfer not found.'), + + 500: internalServerErrorResponse( + InternalServerErrorSchema, + 'Internal server error.', + ), + }, + }, + + put: { + tags: ['Boxes'], + summary: 'Update transfer expiration date', + description: + 'Updates the expiration date of a transfer token. Requires JWT authorization. Only the owner of the box can update its transfer information. The request body can be sent as JSON or form data.', + operationId: 'updateBoxTransfer', + security: [{ bearerAuth: [] }], + + requestParams: { + path: DevicePathParamsSchema, + }, + + requestBody: { + required: true, + content: { + 'application/json': { + schema: UpdateBoxTransferRequestSchema, + }, + 'application/x-www-form-urlencoded': { + schema: UpdateBoxTransferRequestSchema, + }, + }, + }, + + responses: { + 200: { + description: 'Transfer expiration date updated successfully.', + content: { + 'application/json': { + schema: UpdateBoxTransferResponseSchema, + }, + }, + }, + + 400: badRequestResponse( + BoxTransferByDeviceBadRequestErrorSchema, + 'Bad request. This can happen when the token or expiration date is missing or invalid.', + ), + + 403: forbiddenResponse( + ForbiddenErrorSchema, + 'Invalid JWT authorization or the authenticated user is not allowed to update this transfer.', + ), + + 404: notFoundResponse(NotFoundErrorSchema, 'Box or transfer not found.'), + + 405: methodNotAllowedResponse( + MethodNotAllowedErrorSchema, + 'Method not allowed. Only PUT is supported for updating transfer information.', + ), + + 500: internalServerErrorResponse( + InternalServerErrorSchema, + 'Internal server error.', + ), + }, + }, +} + +export const loader = async ({ params, request }: Route.LoaderArgs) => { + const jwtResponse = await getUserFromJwt(request) + + if (typeof jwtResponse === 'string') + return StandardResponse.forbidden( + 'Invalid JWT authorization. Please sign in to obtain new JWT.', + ) + + const { deviceId } = params + + if (!deviceId) return StandardResponse.badRequest('Device ID is required') + + try { + // Get transfer details - will throw if user doesn't own the device or transfer doesn't exist + const transfer = await getBoxTransfer(jwtResponse.id, deviceId) + + return StandardResponse.ok({ + data: { + id: transfer.id, + token: transfer.token, + boxId: transfer.boxId, + expiresAt: transfer.expiresAt, + createdAt: transfer.createdAt, + updatedAt: transfer.updatedAt, + }, + }) + } catch (err) { + console.error('Error fetching transfer:', err) + return handleTransferError(err) + } +} + +export const action = async ({ params, request }: Route.ActionArgs) => { + const jwtResponse = await getUserFromJwt(request) + + if (typeof jwtResponse === 'string') + return StandardResponse.forbidden( + 'Invalid JWT authorization. Please sign in to obtain new JWT.', + ) + + const { deviceId } = params + + if (!deviceId) return StandardResponse.badRequest('Device ID is required') + + if (request.method !== 'PUT') return StandardResponse.methodNotAllowed('') + + const contentType = request.headers.get('content-type') + const isJson = contentType?.includes('application/json') + + return handleUpdateTransfer(request, jwtResponse, deviceId, isJson) +} + +const handleUpdateTransfer = async ( + request: Request, + user: any, + deviceId: string, + isJson: boolean | undefined, +) => { + try { + let token: string | undefined + let expiresAt: string | undefined + + if (isJson) { + const body = await request.json() + token = body.token + expiresAt = body.expiresAt + } else { + const formData = await request.formData() + token = formData.get('token')?.toString() + expiresAt = formData.get('expiresAt')?.toString() + } + + if (!token) return StandardResponse.badRequest('token is required') + + if (!expiresAt) return StandardResponse.badRequest('expiresAt is required') + + const updated = await updateBoxTransferExpiration( + user.id, + deviceId, + token, + expiresAt, + ) + + return StandardResponse.ok({ + message: 'Transfer successfully updated', + data: { + id: updated.id, + boxId: updated.boxId, + token: updated.token, + expiresAt: updated.expiresAt, + createdAt: updated.createdAt, + updatedAt: updated.updatedAt, + }, + }) + } catch (err) { + console.error('Error updating transfer:', err) + return handleTransferError(err) + } +} + +const handleTransferError = (err: unknown) => { + if (err instanceof Error) { + const message = err.message + + if (message.includes('not found')) return StandardResponse.notFound(message) + + if ( + message.includes('permission') || + message.includes("don't have") || + message.includes('not the owner') + ) + return StandardResponse.forbidden(message) + + if ( + message.includes('expired') || + message.includes('Invalid') || + message.includes('required') || + message.includes('format') || + message.includes('future') + ) + return StandardResponse.badRequest(message) + } + + return StandardResponse.internalServerError() +} diff --git a/app/routes/api.boxes.transfer.ts b/app/routes/api.boxes.transfer.ts new file mode 100644 index 000000000..d74e15e19 --- /dev/null +++ b/app/routes/api.boxes.transfer.ts @@ -0,0 +1,314 @@ +import { type Route } from './+types/api.boxes.transfer' +import { getUserFromJwt } from '~/lib/jwt' +import { StandardResponse } from '~/lib/responses' +import { + createBoxTransfer, + removeBoxTransfer, + validateTransferParams, +} from '~/services/transfer-service.server' + +import * as z from 'zod/v4' +import 'zod-openapi' +import { type ZodOpenApiPathItemObject } from 'zod-openapi' + +import { + ForbiddenErrorSchema, + InternalServerErrorSchema, + MethodNotAllowedErrorSchema, + NotFoundErrorSchema, + createBadRequestErrorSchema, +} from '~/lib/openapi/errors' + +import { + badRequestResponse, + forbiddenResponse, + internalServerErrorResponse, + methodNotAllowedResponse, + notFoundResponse, +} from '~/lib/openapi/errors' + +const TransferTokenSchema = z.string().min(1).meta({ + description: 'Transfer token used to claim or revoke the device transfer.', + example: 'clm_01jv7c9x8n0example', +}) + +const CreateBoxTransferRequestSchema = z + .object({ + boxId: z.string().min(1).meta({ + description: 'ID of the device to mark for transfer.', + example: '5bdbe70f55d0ad001a04edc9', + }), + + expiresAt: z.iso.datetime().optional().meta({ + description: + 'Expiration date for the transfer token. If omitted, the default is 24 hours from now.', + example: '2026-05-22T12:00:00.000Z', + }), + }) + .meta({ + id: 'CreateBoxTransferRequest', + description: 'Payload for marking a device for transfer.', + }) + +const RemoveBoxTransferRequestSchema = z + .object({ + boxId: z.string().min(1).meta({ + description: 'ID of the device to remove from transfer.', + example: '5bdbe70f55d0ad001a04edc9', + }), + + token: TransferTokenSchema, + }) + .meta({ + id: 'RemoveBoxTransferRequest', + description: 'Payload for revoking a device transfer token.', + }) + +const CreateBoxTransferResponseSchema = z + .object({ + code: z.literal('Created').default('Created'), + message: z + .literal('Device successfully prepared for transfer') + .default('Device successfully prepared for transfer'), + data: TransferTokenSchema.meta({ + description: 'Generated transfer token.', + }), + }) + .meta({ + id: 'CreateBoxTransferResponse', + description: 'Response returned after creating a transfer token.', + }) + +const BoxTransferBadRequestErrorSchema = createBadRequestErrorSchema({ + id: 'BoxTransferBadRequestError', + description: + 'Bad request. This can happen when required parameters are missing, the expiration date has an invalid format, the expiration date is not in the future, or the transfer token is invalid or expired.', + examples: [ + 'boxId is required', + 'token is required', + 'Invalid date format', + 'Expiration date must be in the future', + 'Invalid or expired transfer token', + ], +}) + +export const openapi: ZodOpenApiPathItemObject = { + post: { + tags: ['Boxes'], + summary: 'Mark a device for transfer', + description: + 'Marks a device for transfer to another user account and returns a transfer token. Requires JWT authorization. The request body can be sent as JSON or form data.', + operationId: 'createBoxTransfer', + security: [{ bearerAuth: [] }], + + requestBody: { + required: true, + content: { + 'application/json': { + schema: CreateBoxTransferRequestSchema, + }, + 'application/x-www-form-urlencoded': { + schema: CreateBoxTransferRequestSchema, + }, + }, + }, + + responses: { + 201: { + description: 'Box successfully prepared for transfer.', + content: { + 'application/json': { + schema: CreateBoxTransferResponseSchema, + }, + }, + }, + + 400: badRequestResponse( + BoxTransferBadRequestErrorSchema, + 'Bad request. This can happen when required parameters are missing or invalid.', + ), + + 403: forbiddenResponse( + ForbiddenErrorSchema, + 'Invalid JWT authorization or the authenticated user is not allowed to transfer this box.', + ), + + 404: notFoundResponse( + NotFoundErrorSchema, + 'Box or transfer record not found.', + ), + + 405: methodNotAllowedResponse( + MethodNotAllowedErrorSchema, + 'Method not allowed. Only POST and DELETE are supported.', + ), + + 500: internalServerErrorResponse( + InternalServerErrorSchema, + 'Internal server error.', + ), + }, + }, + + delete: { + tags: ['Boxes'], + summary: 'Revoke a device transfer token', + description: + 'Revokes a transfer token and removes the device from transfer. Requires JWT authorization. The request body can be sent as JSON or form data.', + operationId: 'removeBoxTransfer', + security: [{ bearerAuth: [] }], + + requestBody: { + required: true, + content: { + 'application/json': { + schema: RemoveBoxTransferRequestSchema, + }, + 'application/x-www-form-urlencoded': { + schema: RemoveBoxTransferRequestSchema, + }, + }, + }, + + responses: { + 204: { + description: 'Transfer token revoked successfully.', + }, + + 400: badRequestResponse( + BoxTransferBadRequestErrorSchema, + 'Bad request. This can happen when `boxId` or `token` is missing or invalid.', + ), + + 403: forbiddenResponse( + ForbiddenErrorSchema, + 'Invalid JWT authorization or the authenticated user is not allowed to revoke this transfer.', + ), + + 404: notFoundResponse( + NotFoundErrorSchema, + 'Box or transfer record not found.', + ), + + 405: methodNotAllowedResponse( + MethodNotAllowedErrorSchema, + 'Method not allowed. Only POST and DELETE are supported.', + ), + + 500: internalServerErrorResponse( + InternalServerErrorSchema, + 'Internal server error.', + ), + }, + }, +} + +export const action = async ({ request }: Route.ActionArgs) => { + const jwtResponse = await getUserFromJwt(request) + + if (typeof jwtResponse === 'string') + return StandardResponse.forbidden( + 'Invalid JWT authorization. Please sign in to obtain new JWT.', + ) + + if (request.method !== 'POST' && request.method !== 'DELETE') + return StandardResponse.methodNotAllowed('') + + switch (request.method) { + case 'POST': { + return handleCreateTransfer(request, jwtResponse) + } + case 'DELETE': { + return handleRemoveTransfer(request, jwtResponse) + } + } +} + +const handleCreateTransfer = async (request: Request, user: any) => { + try { + let boxId: string | undefined + let expiresAt: string | undefined + + const contentType = request.headers.get('content-type') + if (contentType?.includes('application/json')) { + const body = await request.json() + boxId = body.boxId + expiresAt = body.expiresAt || body.date // Support both param names for backwards compatibility + } else { + const formData = await request.formData() + boxId = formData.get('boxId')?.toString() + expiresAt = + formData.get('expiresAt')?.toString() || + formData.get('date')?.toString() + } + + const validation = validateTransferParams(boxId, expiresAt) + if (!validation.isValid) + return StandardResponse.badRequest(validation.error ?? '') + + const transferCode = await createBoxTransfer(user.id, boxId!, expiresAt) + + return StandardResponse.created({ + message: 'Box successfully prepared for transfer', + data: transferCode, + }) + } catch (err) { + console.error('Error creating transfer:', err) + return handleTransferError(err) + } +} + +const handleRemoveTransfer = async (request: Request, user: any) => { + try { + let boxId: string | undefined + let token: string | undefined + + const contentType = request.headers.get('content-type') + if (contentType?.includes('application/json')) { + const body = await request.json() + boxId = body.boxId + token = body.token + } else { + const formData = await request.formData() + boxId = formData.get('boxId')?.toString() + token = formData.get('token')?.toString() + } + + if (!boxId) return StandardResponse.badRequest('boxId is required') + + if (!token) return StandardResponse.badRequest('token is required') + + await removeBoxTransfer(user.id, boxId, token) + + return StandardResponse.noContent() + } catch (err) { + console.error('Error removing transfer:', err) + return handleTransferError(err) + } +} + +const handleTransferError = (err: unknown) => { + if (err instanceof Error) { + const message = err.message + + if (message.includes('not found')) return StandardResponse.notFound(message) + + if ( + message.includes('permission') || + message.includes("don't have") || + message.includes('not the owner') + ) + return StandardResponse.forbidden(message) + + if ( + message.includes('expired') || + message.includes('Invalid') || + message.includes('required') || + message.includes('format') || + message.includes('future') + ) + return StandardResponse.badRequest(message) + } + + return StandardResponse.internalServerError() +} diff --git a/app/routes/api.boxes.ts b/app/routes/api.boxes.ts index 17fc5401d..7992e4213 100644 --- a/app/routes/api.boxes.ts +++ b/app/routes/api.boxes.ts @@ -13,323 +13,133 @@ import { CreateBoxSchema, } from '~/services/device-service.server' -/** - * @openapi - * /api/boxes: - * post: - * tags: - * - Boxes - * summary: Create a new box - * description: Creates a new box/device with sensors - * operationId: createBox - * security: - * - bearerAuth: [] - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * required: - * - name - * - location - * properties: - * name: - * type: string - * description: Box name - * example: "trala" - * exposure: - * type: string - * enum: ["indoor", "outdoor", "mobile", "unknown"] - * description: Box exposure type - * example: "mobile" - * location: - * type: array - * items: - * type: number - * minItems: 2 - * maxItems: 2 - * description: Box location as [longitude, latitude] - * example: [-122.406417, 37.785834] - * grouptag: - * type: array - * items: - * type: string - * description: Box group tags - * example: ["bike", "atrai", "arnsberg"] - * model: - * type: string - * enum: ["homeV2Lora", "homeV2Ethernet", "homeV2Wifi", "senseBox:Edu", "luftdaten.info", "custom"] - * description: Box model type - * example: "custom" - * sensors: - * type: array - * items: - * type: object - * required: - * - id - * - title - * - sensorType - * - unit - * properties: - * id: - * type: string - * description: Sensor ID - * example: "0" - * icon: - * type: string - * description: Sensor icon - * example: "osem-thermometer" - * title: - * type: string - * description: Sensor title - * example: "Temperature" - * sensorType: - * type: string - * description: Sensor type - * example: "HDC1080" - * unit: - * type: string - * description: Sensor unit - * example: "°C" - * responses: - * 201: - * description: Box created successfully - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/Box' - * 400: - * description: Bad request - validation error - * content: - * application/json: - * schema: - * type: object - * properties: - * code: - * type: string - * example: "Bad Request" - * message: - * type: string - * example: "Invalid request data" - * errors: - * type: array - * items: - * type: string - * 403: - * description: Forbidden - invalid or missing JWT token - * content: - * application/json: - * schema: - * type: object - * properties: - * code: - * type: string - * example: "Forbidden" - * message: - * type: string - * example: "Invalid JWT authorization. Please sign in to obtain new JWT." - * 405: - * description: Method not allowed - only POST is supported - * content: - * application/json: - * schema: - * type: object - * properties: - * message: - * type: string - * example: "Method Not Allowed" - * 500: - * description: Internal server error - * content: - * application/json: - * schema: - * type: object - * properties: - * code: - * type: string - * example: "Internal Server Error" - * message: - * type: string - * example: "The server was unable to create the box. Please try again later." - * components: - * schemas: - * Box: - * type: object - * description: Box/Device object - * properties: - * _id: - * type: string - * description: Unique box identifier - * example: "clx1234567890abcdef" - * name: - * type: string - * description: Box name - * example: "My Weather Station" - * description: - * type: string - * description: Box description - * example: "A weather monitoring station" - * image: - * type: string - * format: uri - * description: Box image URL - * example: "https://example.com/image.jpg" - * link: - * type: string - * format: uri - * description: Box website link - * example: "https://example.com" - * grouptag: - * type: array - * items: - * type: string - * description: Box group tags - * example: ["weather", "outdoor"] - * exposure: - * type: string - * enum: ["indoor", "outdoor", "mobile", "unknown"] - * description: Box exposure type - * example: "outdoor" - * model: - * type: string - * enum: ["homeV2Lora", "homeV2Ethernet", "homeV2Wifi", "senseBox:Edu", "luftdaten.info", "custom"] - * description: Box model - * example: "homeV2Wifi" - * latitude: - * type: number - * description: Box latitude - * example: 52.520008 - * longitude: - * type: number - * description: Box longitude - * example: 13.404954 - * useAuth: - * type: boolean - * description: Whether box requires authentication - * example: true - * public: - * type: boolean - * description: Whether box is public - * example: false - * status: - * type: string - * enum: ["active", "inactive", "old"] - * description: Box status - * example: "inactive" - * createdAt: - * type: string - * format: date-time - * description: Box creation timestamp - * example: "2024-01-15T10:30:00Z" - * updatedAt: - * type: string - * format: date-time - * description: Box last update timestamp - * example: "2024-01-15T10:30:00Z" - * expiresAt: - * type: string - * format: date-time - * nullable: true - * description: Box expiration date - * example: "2024-12-31T23:59:59Z" - * userId: - * type: string - * description: Owner user ID - * example: "user_123456" - * sensorWikiModel: - * type: string - * nullable: true - * description: Sensor Wiki model identifier - * example: "homeV2Wifi" - * currentLocation: - * type: object - * description: Current location as GeoJSON Point - * properties: - * type: - * type: string - * example: "Point" - * coordinates: - * type: array - * items: - * type: number - * example: [13.404954, 52.520008] - * timestamp: - * type: string - * format: date-time - * example: "2023-01-01T00:00:00.000Z" - * lastMeasurementAt: - * type: string - * format: date-time - * description: Last measurement timestamp - * example: "2023-01-01T00:00:00.000Z" - * loc: - * type: array - * description: Location history as GeoJSON features - * items: - * type: object - * properties: - * type: - * type: string - * example: "Feature" - * geometry: - * type: object - * properties: - * type: - * type: string - * example: "Point" - * coordinates: - * type: array - * items: - * type: number - * example: [13.404954, 52.520008] - * timestamp: - * type: string - * format: date-time - * example: "2023-01-01T00:00:00.000Z" - * integrations: - * type: object - * description: Box integrations - * properties: - * mqtt: - * type: object - * properties: - * enabled: - * type: boolean - * example: false - * sensors: - * type: array - * items: - * type: object - * properties: - * _id: - * type: string - * description: Sensor ID - * example: "sensor123" - * title: - * type: string - * description: Sensor title - * example: "Temperature" - * unit: - * type: string - * description: Sensor unit - * example: "°C" - * sensorType: - * type: string - * description: Sensor type - * example: "HDC1080" - * lastMeasurement: - * type: object - * description: Last measurement data - * properties: - * createdAt: - * type: string - * format: date-time - * example: "2023-01-01T00:00:00.000Z" - * value: - * type: string - * example: "25.13" - */ +import { type ZodOpenApiPathItemObject } from 'zod-openapi' +import { + ApiDeviceSchema, + DevicesGeoJsonResponseSchema, + DevicesResponseSchema, +} from '~/lib/openapi/schemas/device' +import { + BadRequestErrorSchema, + badRequestResponse, + ForbiddenErrorSchema, + forbiddenResponse, + internalServerErrorResponse, + InternalServerErrorSchema, + MethodNotAllowedErrorSchema, + methodNotAllowedResponse, + UnprocessableContentErrorSchema, + unprocessableContentResponse, +} from '~/lib/openapi/errors' +import { requestContentTypeJson } from '~/middleware/content-type-header.server' + +const BoxesQueryParamsSchema = BoxesQuerySchema.meta({ + id: 'BoxesQueryParams', + description: + 'Query parameters used to filter and format the devices response.', +}) + +const CreateBoxRequestSchema = CreateBoxSchema.meta({ + id: 'CreateBoxRequest', + description: 'Payload for creating a new device.', +}) + +const CreatedBoxResponseSchema = ApiDeviceSchema.meta({ + id: 'CreatedBoxResponse', + description: + 'Created box/device response transformed through `transformDeviceToApiFormat`.', +}) + +export const openapi: ZodOpenApiPathItemObject = { + get: { + tags: ['Boxes'], + summary: 'Get devices', + description: + 'Find devices using query parameters. By default, a JSON array of devices is returned. If `format=geojson`, a GeoJSON FeatureCollection is returned.', + operationId: 'findBoxes', + + requestParams: { + query: BoxesQueryParamsSchema, + }, + + responses: { + 200: { + description: 'Devices retrieved successfully.', + content: { + 'application/json': { + schema: DevicesResponseSchema, + }, + 'application/geo+json': { + schema: DevicesGeoJsonResponseSchema, + }, + }, + }, + + 422: unprocessableContentResponse( + UnprocessableContentErrorSchema, + 'Invalid query parameter.', + ), + + 500: internalServerErrorResponse( + InternalServerErrorSchema, + 'Internal server error.', + ), + }, + }, + + post: { + tags: ['Boxes'], + summary: 'Create a new box', + description: 'Creates a new box/device with optional sensors.', + operationId: 'createBox', + security: [{ bearerAuth: [] }], + + requestBody: { + required: true, + content: { + 'application/json': { + schema: CreateBoxRequestSchema, + }, + }, + }, + + responses: { + 201: { + description: 'Box created successfully.', + content: { + 'application/json': { + schema: CreatedBoxResponseSchema, + }, + }, + }, + + 400: badRequestResponse( + BadRequestErrorSchema, + 'Bad request. This can happen when the request body is not valid JSON or does not match CreateBoxSchema.', + ), + + 403: forbiddenResponse( + ForbiddenErrorSchema, + 'Invalid or missing JWT authorization.', + ), + + 405: methodNotAllowedResponse( + MethodNotAllowedErrorSchema, + 'Method not allowed.', + ), + + 500: internalServerErrorResponse( + InternalServerErrorSchema, + 'Internal server error.', + ), + }, + }, +} + +export const middleware: Route.MiddlewareFunction[] = [ + requestContentTypeJson(['POST']), +] + export async function loader({ request }: Route.LoaderArgs) { const url = new URL(request.url) const queryObj = Object.fromEntries(url.searchParams) @@ -364,10 +174,17 @@ export async function loader({ request }: Route.LoaderArgs) { })), } - return geojson - } else { - return devices + return Response.json(geojson, { + headers: { + 'Content-Type': 'application/geo+json; charset=utf-8', + }, + }) } + return Response.json(devices, { + headers: { + 'Content-Type': 'application/json; charset=utf-8', + }, + }) } export const action = async ({ request }: Route.ActionArgs) => { diff --git a/app/routes/api.claim.ts b/app/routes/api.claim.ts deleted file mode 100644 index 8042c7ff6..000000000 --- a/app/routes/api.claim.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { type Route } from './+types/api.claim' -import { getUserFromJwt } from '~/lib/jwt' -import { StandardResponse } from '~/lib/responses' -import { claimBox } from '~/services/transfer-service.server' - -export const action = async ({ request }: Route.ActionArgs) => { - const contentType = request.headers.get('content-type') - if (!contentType || !contentType.includes('application/json')) - return StandardResponse.unsupportedMediaType( - 'Unsupported content-type. Try application/json', - ) - - if (request.method !== 'POST') - return StandardResponse.methodNotAllowed('Only POST allowed') - - const jwtResponse = await getUserFromJwt(request) - - if (typeof jwtResponse === 'string') - return StandardResponse.forbidden('Invalid JWT. Please sign in') - - try { - const body = await request.json() - const { token } = body - - if (!token) return StandardResponse.badRequest('token is required') - - const result = await claimBox(jwtResponse.id, token) - - return StandardResponse.ok({ - message: 'Device successfully claimed!', - data: result, - }) - } catch (err) { - console.error('Error claiming box:', err) - return handleClaimError(err) - } -} - -const handleClaimError = (err: unknown) => { - if (err instanceof Error) { - const message = err.message - - if (message.includes('expired') || message.includes('Invalid or expired')) - return StandardResponse.gone(message) - - if (message.includes('not found')) return StandardResponse.notFound(message) - - if ( - message.includes('required') || - message.includes('Invalid') || - message.includes('already own') - ) - return StandardResponse.badRequest(message) - } - - return StandardResponse.internalServerError() -} diff --git a/app/routes/api.getsensors.ts b/app/routes/api.getsensors.ts deleted file mode 100644 index 35ed4ce4a..000000000 --- a/app/routes/api.getsensors.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { type Route } from './+types/api.getsensors' -import { getSensors } from '~/db/models/sensor.server' -import { StandardResponse } from '~/lib/responses' - -/** - * @openapi - * /api/getsensors: - * get: - * tags: - * - Sensors - * summary: Get sensors by device ID - * description: Returns a list of sensors associated with the specified device ID - * parameters: - * - in: query - * name: deviceId - * required: true - * schema: - * type: string - * description: The ID of the device to fetch sensors for - * example: "device-123" - * responses: - * 200: - * description: Successful operation - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: '#/components/schemas/Sensor' - * example: - * - id: "sensor-1" - * name: "Temperature Sensor" - * type: "temperature" - * - id: "sensor-2" - * name: "Humidity Sensor" - * type: "humidity" - * 400: - * description: Bad request - deviceId parameter is missing - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: - * error: "deviceId is required" - * 500: - * description: Internal server error - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: - * error: "Failed to fetch sensors" - * - * components: - * schemas: - * Sensor: - * type: object - * properties: - * id: - * type: string - * description: Unique identifier for the sensor - * example: "sensor-1" - * name: - * type: string - * description: Human-readable name of the sensor - * example: "Temperature Sensor" - * type: - * type: string - * description: Type of sensor - * example: "temperature" - * required: - * - id - * - name - */ - -export const loader = async ({ request }: Route.LoaderArgs) => { - const url = new URL(request.url) - const deviceId = url.searchParams.get('deviceId') - if (!deviceId) return StandardResponse.badRequest('deviceId is required') - try { - const sensors = await getSensors(deviceId) - return new Response(JSON.stringify(sensors), { - status: 200, - headers: { - 'Content-Type': 'application/json; charset=utf-8', - 'Cache-Control': 'no-cache', - }, - }) - } catch (error) { - return StandardResponse.internalServerError('Failed to fetch sensors') - } -} diff --git a/app/routes/api.measurements.ts b/app/routes/api.measurements.ts deleted file mode 100644 index 7afecde70..000000000 --- a/app/routes/api.measurements.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { type Route } from './+types/api.measurements' -import { measurement, type Measurement } from '~/db/schema' -import { drizzleClient } from '~/db.server' -import { StandardResponse } from '~/lib/responses' - -/** - * @openapi - * /api/measurements: - * post: - * tags: - * - Measurements - * summary: Create new measurements - * description: Accepts an array of measurement data and stores it in the database - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: '#/components/schemas/Measurement' - * example: - * - sensorId: "sensor-123" - * time: "2023-05-15T10:00:00Z" - * value: 25.4 - * - sensorId: "sensor-456" - * time: "2023-05-15T10:01:00Z" - * value: 22.1 - * responses: - * 200: - * description: Measurements successfully stored - * content: - * application/json: - * schema: - * type: object - * properties: - * message: - * type: string - * example: "Measurements successfully stored" - * 400: - * description: Invalid data format - * content: - * application/json: - * schema: - * type: object - * properties: - * message: - * type: string - * example: "Invalid data format" - * 405: - * description: Method not allowed - * content: - * application/json: - * schema: - * type: object - * properties: - * message: - * type: string - * example: "Method not allowed" - * - * components: - * schemas: - * Measurement: - * type: object - * required: - * - sensorId - * - time - * - value - * properties: - * sensorId: - * type: string - * description: Unique identifier for the sensor - * example: "sensor-123" - * time: - * type: string - * format: date-time - * description: Timestamp of the measurement - * example: "2023-05-15T10:00:00Z" - * value: - * type: number - * format: float - * description: Measured value - * example: 25.4 - */ -export const action = async ({ request }: Route.ActionArgs) => { - if (request.method !== 'POST') - return StandardResponse.methodNotAllowed('Method not allowed') - - try { - const payload: Measurement[] = await request.json() - - const measurements = payload.map((data) => ({ - sensorId: data.sensorId, - time: new Date(data.time), - value: Number(data.value), - })) - - await drizzleClient.insert(measurement).values(measurements) - - return StandardResponse.ok('Measurements successfully stored') - } catch (error) { - return StandardResponse.badRequest(`${error}`) - } -} diff --git a/app/routes/api.stats.ts b/app/routes/api.stats.ts index 292992f6d..bcc97dc58 100644 --- a/app/routes/api.stats.ts +++ b/app/routes/api.stats.ts @@ -2,25 +2,131 @@ import { type Route } from './+types/api.stats' import { StandardResponse } from '~/lib/responses' import { getStatistics } from '~/services/statistics-service.server' +import * as z from 'zod/v4' +import { type ZodOpenApiPathItemObject } from 'zod-openapi' + +import { + InternalServerErrorSchema, + createBadRequestErrorSchema, + badRequestResponse, + internalServerErrorResponse, +} from '~/lib/openapi/errors' + +const StatsQueryParamsSchema = z + .object({ + human: z.enum(['true', 'false']).optional().meta({ + description: + 'If `true`, returns compact human-readable values instead of numbers.', + example: 'false', + }), + }) + .meta({ + id: 'StatsQueryParams', + description: 'Query parameters for statistics.', + }) + +const NumericStatsResponseSchema = z + .tuple([z.number(), z.number(), z.number()]) + .meta({ + id: 'NumericStatsResponse', + description: + 'Statistics as numeric values: device count, sensor count, and measurement count from the last minute.', + example: [318, 1024, 393], + }) + +const HumanReadableStatsResponseSchema = z + .tuple([z.string(), z.string(), z.string()]) + .meta({ + id: 'HumanReadableStatsResponse', + description: + 'Statistics as compact human-readable strings: device count, sensor count, and measurement count from the last minute.', + example: ['318', '1K', '393'], + }) + +const StatsResponseSchema = z + .union([NumericStatsResponseSchema, HumanReadableStatsResponseSchema]) + .meta({ + id: 'StatsResponse', + description: + 'Statistics response. Returns numbers by default and compact strings when `human=true`.', + }) + +const StatsBadRequestErrorSchema = createBadRequestErrorSchema({ + id: 'StatsBadRequestError', + description: + 'Bad request. This happens when the `human` query parameter has an unsupported value.', + examples: ['Illegal value for parameter human. allowed values: true, false'], +}) + +export const openapi: ZodOpenApiPathItemObject = { + get: { + tags: ['Statistics'], + summary: 'Get platform statistics', + description: + 'Returns platform statistics as an array with three values: the number of devices, the number of measurements, and the number of measurements recorded in the last minute. By default the values are numbers. If `human=true`, compact human-readable strings are returned.', + operationId: 'getStatistics', + + requestParams: { + query: StatsQueryParamsSchema, + }, + + responses: { + 200: { + description: 'Statistics returned successfully.', + content: { + 'application/json': { + schema: StatsResponseSchema, + }, + }, + }, + + 400: badRequestResponse( + StatsBadRequestErrorSchema, + 'Bad request. The `human` query parameter must be either `true` or `false`.', + ), + + 500: internalServerErrorResponse( + InternalServerErrorSchema, + 'Internal server error.', + ), + }, + }, +} + +const parseStatsQueryParams = (request: Request) => { + const url = new URL(request.url) + const query = Object.fromEntries(url.searchParams) + + const parsed = StatsQueryParamsSchema.safeParse(query) + + if (!parsed.success) { + return StandardResponse.badRequest( + 'Illegal value for parameter human. allowed values: true, false', + ) + } + + return parsed.data +} + export async function loader({ request }: Route.LoaderArgs) { try { - const url = new URL(request.url) - const humanParam = url.searchParams.get('human') - - let humanReadable = false - if ( - humanParam !== null && - humanParam.toLowerCase() !== 'true' && - humanParam.toLowerCase() !== 'false' - ) - return StandardResponse.badRequest( - 'Illegal value for parameter human. allowed values: true, false', - ) + const query = parseStatsQueryParams(request) - humanReadable = humanParam?.toLowerCase() === 'true' || false + if (query instanceof Response) { + return query + } + const humanReadable = query.human === 'true' const stats = await getStatistics(humanReadable) - return StandardResponse.ok(stats) + + const responseParsed = await StatsResponseSchema.safeParseAsync(stats) + + if (!responseParsed.success) { + console.warn(responseParsed.error) + return StandardResponse.internalServerError() + } + + return StandardResponse.ok(responseParsed.data) } catch (e) { console.warn(e) return StandardResponse.internalServerError() diff --git a/app/routes/api.transfer.$deviceId.ts b/app/routes/api.transfer.$deviceId.ts deleted file mode 100644 index 75434bf80..000000000 --- a/app/routes/api.transfer.$deviceId.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { type Route } from './+types/api.transfer.$deviceId' -import { getUserFromJwt } from '~/lib/jwt' -import { StandardResponse } from '~/lib/responses' -import { - getBoxTransfer, - updateBoxTransferExpiration, -} from '~/services/transfer-service.server' - -export const loader = async ({ params, request }: Route.LoaderArgs) => { - const jwtResponse = await getUserFromJwt(request) - - if (typeof jwtResponse === 'string') - return StandardResponse.forbidden( - 'Invalid JWT authorization. Please sign in to obtain new JWT.', - ) - - const { deviceId } = params - - if (!deviceId) return StandardResponse.badRequest('Device ID is required') - - try { - // Get transfer details - will throw if user doesn't own the device or transfer doesn't exist - const transfer = await getBoxTransfer(jwtResponse.id, deviceId) - - return StandardResponse.ok({ - data: { - id: transfer.id, - token: transfer.token, - boxId: transfer.boxId, - expiresAt: transfer.expiresAt, - createdAt: transfer.createdAt, - updatedAt: transfer.updatedAt, - }, - }) - } catch (err) { - console.error('Error fetching transfer:', err) - return handleTransferError(err) - } -} - -export const action = async ({ params, request }: Route.ActionArgs) => { - const jwtResponse = await getUserFromJwt(request) - - if (typeof jwtResponse === 'string') - return StandardResponse.forbidden( - 'Invalid JWT authorization. Please sign in to obtain new JWT.', - ) - - const { deviceId } = params - - if (!deviceId) return StandardResponse.badRequest('Device ID is required') - - if (request.method !== 'PUT') return StandardResponse.methodNotAllowed('') - - const contentType = request.headers.get('content-type') - const isJson = contentType?.includes('application/json') - - return handleUpdateTransfer(request, jwtResponse, deviceId, isJson) -} - -const handleUpdateTransfer = async ( - request: Request, - user: any, - deviceId: string, - isJson: boolean | undefined, -) => { - try { - let token: string | undefined - let expiresAt: string | undefined - - if (isJson) { - const body = await request.json() - token = body.token - expiresAt = body.expiresAt - } else { - const formData = await request.formData() - token = formData.get('token')?.toString() - expiresAt = formData.get('expiresAt')?.toString() - } - - if (!token) return StandardResponse.badRequest('token is required') - - if (!expiresAt) return StandardResponse.badRequest('expiresAt is required') - - const updated = await updateBoxTransferExpiration( - user.id, - deviceId, - token, - expiresAt, - ) - - return StandardResponse.ok({ - message: 'Transfer successfully updated', - data: { - id: updated.id, - boxId: updated.boxId, - token: updated.token, - expiresAt: updated.expiresAt, - createdAt: updated.createdAt, - updatedAt: updated.updatedAt, - }, - }) - } catch (err) { - console.error('Error updating transfer:', err) - return handleTransferError(err) - } -} - -const handleTransferError = (err: unknown) => { - if (err instanceof Error) { - const message = err.message - - if (message.includes('not found')) return StandardResponse.notFound(message) - - if ( - message.includes('permission') || - message.includes("don't have") || - message.includes('not the owner') - ) - return StandardResponse.forbidden(message) - - if ( - message.includes('expired') || - message.includes('Invalid') || - message.includes('required') || - message.includes('format') || - message.includes('future') - ) - return StandardResponse.badRequest(message) - } - - return StandardResponse.internalServerError() -} diff --git a/app/routes/api.transfer.ts b/app/routes/api.transfer.ts deleted file mode 100644 index b286f11b0..000000000 --- a/app/routes/api.transfer.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { type Route } from './+types/api.transfer' -import { getUserFromJwt } from '~/lib/jwt' -import { StandardResponse } from '~/lib/responses' -import { - createBoxTransfer, - removeBoxTransfer, - validateTransferParams, -} from '~/services/transfer-service.server' - -export const action = async ({ request }: Route.ActionArgs) => { - const jwtResponse = await getUserFromJwt(request) - - if (typeof jwtResponse === 'string') - return StandardResponse.forbidden( - 'Invalid JWT authorization. Please sign in to obtain new JWT.', - ) - - if (request.method !== 'POST' && request.method !== 'DELETE') - return StandardResponse.methodNotAllowed('') - - switch (request.method) { - case 'POST': { - return handleCreateTransfer(request, jwtResponse) - } - case 'DELETE': { - return handleRemoveTransfer(request, jwtResponse) - } - } -} - -const handleCreateTransfer = async (request: Request, user: any) => { - try { - let boxId: string | undefined - let expiresAt: string | undefined - - const contentType = request.headers.get('content-type') - if (contentType?.includes('application/json')) { - const body = await request.json() - boxId = body.boxId - expiresAt = body.expiresAt || body.date // Support both param names for backwards compatibility - } else { - const formData = await request.formData() - boxId = formData.get('boxId')?.toString() - expiresAt = - formData.get('expiresAt')?.toString() || - formData.get('date')?.toString() - } - - const validation = validateTransferParams(boxId, expiresAt) - if (!validation.isValid) - return StandardResponse.badRequest(validation.error ?? '') - - const transferCode = await createBoxTransfer(user.id, boxId!, expiresAt) - - return StandardResponse.created({ - message: 'Box successfully prepared for transfer', - data: transferCode, - }) - } catch (err) { - console.error('Error creating transfer:', err) - return handleTransferError(err) - } -} - -const handleRemoveTransfer = async (request: Request, user: any) => { - try { - let boxId: string | undefined - let token: string | undefined - - const contentType = request.headers.get('content-type') - if (contentType?.includes('application/json')) { - const body = await request.json() - boxId = body.boxId - token = body.token - } else { - const formData = await request.formData() - boxId = formData.get('boxId')?.toString() - token = formData.get('token')?.toString() - } - - if (!boxId) return StandardResponse.badRequest('boxId is required') - - if (!token) return StandardResponse.badRequest('token is required') - - await removeBoxTransfer(user.id, boxId, token) - - return StandardResponse.noContent() - } catch (err) { - console.error('Error removing transfer:', err) - return handleTransferError(err) - } -} - -const handleTransferError = (err: unknown) => { - if (err instanceof Error) { - const message = err.message - - if (message.includes('not found')) return StandardResponse.notFound(message) - - if ( - message.includes('permission') || - message.includes("don't have") || - message.includes('not the owner') - ) - return StandardResponse.forbidden(message) - - if ( - message.includes('expired') || - message.includes('Invalid') || - message.includes('required') || - message.includes('format') || - message.includes('future') - ) - return StandardResponse.badRequest(message) - } - - return StandardResponse.internalServerError() -} diff --git a/app/routes/api.ts b/app/routes/api.ts index 55bce50ad..33a0e4aea 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.me.boxes.$deviceId.ts b/app/routes/api.users.me.boxes.$deviceId.ts index 411843f21..86c41179c 100644 --- a/app/routes/api.users.me.boxes.$deviceId.ts +++ b/app/routes/api.users.me.boxes.$deviceId.ts @@ -3,6 +3,85 @@ import { getDevice } from '~/db/models/device.server' import { getUserFromJwt } from '~/lib/jwt' import { StandardResponse } from '~/lib/responses' +import * as z from 'zod/v4' +import 'zod-openapi' +import { type ZodOpenApiPathItemObject } from 'zod-openapi' + +import { DevicePathParamsSchema } from '~/lib/openapi/schemas/common' +import { ApiDeviceSchema } from '~/lib/openapi/schemas/device' + +import { + BadRequestErrorSchema, + ForbiddenErrorSchema, + InternalServerErrorSchema, +} from '~/lib/openapi/errors' + +import { + badRequestResponse, + forbiddenResponse, + internalServerErrorResponse, +} from '~/lib/openapi/errors' + +const CurrentUserPrivateDeviceSchema = ApiDeviceSchema.meta({ + id: 'CurrentUserPrivateDevice', + description: + 'Device owned by the authenticated user. This response may include private or secret fields.', +}) + +const GetCurrentUserDeviceResponseSchema = z + .object({ + code: z.literal('Ok').default('Ok'), + data: z.object({ + box: CurrentUserPrivateDeviceSchema, + }), + }) + .meta({ + id: 'GetCurrentUserBoxResponse', + description: + 'Response containing one device owned by the authenticated user.', + }) + +export const openapi: ZodOpenApiPathItemObject = { + get: { + tags: ['User Management'], + summary: 'Get one device of the current user', + description: + 'Returns a specific device owned by the authenticated user. This endpoint may include private or secret fields that are not returned by public device endpoints.', + operationId: 'getCurrentUserDevice', + security: [{ bearerAuth: [] }], + + requestParams: { + path: DevicePathParamsSchema, + }, + + responses: { + 200: { + description: 'Device returned successfully.', + content: { + 'application/json': { + schema: GetCurrentUserDeviceResponseSchema, + }, + }, + }, + + 400: badRequestResponse( + BadRequestErrorSchema, + 'Bad request. The device ID is missing, invalid, or no device exists for the given ID.', + ), + + 403: forbiddenResponse( + ForbiddenErrorSchema, + 'Invalid JWT authorization or the authenticated user does not own this device.', + ), + + 500: internalServerErrorResponse( + InternalServerErrorSchema, + 'Internal server error.', + ), + }, + }, +} + export const loader = async ({ request, params }: Route.LoaderArgs) => { try { const jwtResponse = await getUserFromJwt(request) @@ -24,7 +103,7 @@ export const loader = async ({ request, params }: Route.LoaderArgs) => { ) if (box.user.id !== user.id) - return StandardResponse.forbidden('User does not own this senseBox') + return StandardResponse.forbidden('User does not own this device') return StandardResponse.ok({ code: 'Ok', data: { box: box } }) } catch (err) { diff --git a/app/routes/api.users.me.resend-email-confirmation.ts b/app/routes/api.users.me.resend-email-confirmation.ts index 9f460f968..4d3d9c66e 100644 --- a/app/routes/api.users.me.resend-email-confirmation.ts +++ b/app/routes/api.users.me.resend-email-confirmation.ts @@ -3,6 +3,80 @@ import { getUserFromJwt } from '~/lib/jwt' import { StandardResponse } from '~/lib/responses' import { resendEmailConfirmation } from '~/services/user-service.server' +import * as z from 'zod/v4' +import 'zod-openapi' +import { type ZodOpenApiPathItemObject } from 'zod-openapi' + +import { + ForbiddenErrorSchema, + InternalServerErrorSchema, + createUnprocessableContentErrorSchema, +} from '~/lib/openapi/errors' + +import { + forbiddenResponse, + internalServerErrorResponse, + unprocessableContentResponse, +} from '~/lib/openapi/errors' + +const ResendEmailConfirmationResponseSchema = z + .object({ + code: z.literal('Ok').default('Ok'), + message: z.string().meta({ + description: + 'Confirmation that the email confirmation message has been sent.', + example: 'Email confirmation has been sent to user@example.com', + }), + }) + .meta({ + id: 'ResendEmailConfirmationResponse', + description: 'Email confirmation resend response.', + }) + +const EmailAlreadyConfirmedErrorSchema = createUnprocessableContentErrorSchema({ + id: 'EmailAlreadyConfirmedError', + description: + 'Returned when the user email address is already confirmed and there is no pending unconfirmed email address.', + examples: ['Email address user@example.com is already confirmed.'], +}) + +export const openapi: ZodOpenApiPathItemObject = { + post: { + tags: ['User Management'], + summary: 'Resend email confirmation', + description: + 'Requests another email confirmation message for the authenticated user. If the user has a pending unconfirmed email address, the confirmation email is sent to that pending address. Otherwise it is sent to the current user email address. If the email address is already confirmed and there is no pending email change, the request returns 422.', + operationId: 'resendEmailConfirmation', + security: [{ bearerAuth: [] }], + + responses: { + 200: { + description: 'Email confirmation sent successfully.', + content: { + 'application/json': { + schema: ResendEmailConfirmationResponseSchema, + }, + }, + }, + + 403: forbiddenResponse( + ForbiddenErrorSchema, + 'Invalid or missing JWT authorization.', + ), + + 422: unprocessableContentResponse( + EmailAlreadyConfirmedErrorSchema, + 'The email address is already confirmed.', + ), + + 500: internalServerErrorResponse( + InternalServerErrorSchema, + 'Internal server error.', + ), + }, + }, +} + export const action = async ({ request }: Route.ActionArgs) => { try { const jwtResponse = await getUserFromJwt(request) @@ -20,9 +94,11 @@ export const action = async ({ request }: Route.ActionArgs) => { ) } + const recipient = result.unconfirmedEmail?.trim() || result.email + return StandardResponse.ok({ code: 'Ok', - message: `Email confirmation has been sent to ${result.unconfirmedEmail}`, + message: `Email confirmation has been sent to ${recipient}`, }) } catch (err) { console.warn(err) diff --git a/app/routes/api.users.me.ts b/app/routes/api.users.me.ts index b853dd374..56add587b 100644 --- a/app/routes/api.users.me.ts +++ b/app/routes/api.users.me.ts @@ -1,289 +1,337 @@ +import { z } from 'zod' +import { type ZodOpenApiPathItemObject } from 'zod-openapi' import { getUserDeviceIds } from '~/db/models/device.server' -import { type Route } from './+types/api.users.me' import { type User } from '~/db/schema/user' -import { getUserFromJwt } from '~/lib/jwt' +import { getAuthenticatedUser } from '~/lib/jwt' import { StandardResponse } from '~/lib/responses' import { deleteUser, updateUserDetails } from '~/services/user-service.server' +import { type Route } from './+types/api.users.me' +import { + UserLanguageSchema, + UserSchema, + UserWithBoxesSchema, +} from '~/lib/openapi/schemas/user' +import { + BadRequestErrorSchema, + badRequestResponse, + ForbiddenErrorSchema, + forbiddenResponse, + internalServerErrorResponse, + InternalServerErrorSchema, + MethodNotAllowedErrorSchema, + methodNotAllowedResponse, + UnauthorizedErrorSchema, + unauthorizedResponse, +} from '~/lib/openapi/errors' +import { apiMessages } from '~/lib/openapi/messages' +import { transformUserToApiFormat } from '~/lib/user-transform' +import { + requestContentTypeJson, + requestContentTypeJsonOrForm, +} from '~/middleware/content-type-header.server' +import { parseFormRequest, parseJsonBody } from '~/lib/request-parsing' + +const messages = { + noChanges: 'No changed properties supplied. User remains unchanged.', + badRequest: 'Bad Request', + currentPasswordRequired: + 'Current password is required when setting a new password', + passwordIncorrect: 'Password incorrect', +} as const + +const GetMeResponseSchema = z + .object({ + code: z.literal('Ok').default('Ok'), + data: z.object({ + me: UserWithBoxesSchema, + }), + }) + .meta({ + id: 'GetCurrentUserResponse', + description: 'Current authenticated user including device ids.', + }) + +const UpdateCurrentUserRequestSchema = z + .object({ + email: z.email().optional().meta({ + description: 'New email address', + example: 'newemail@example.com', + }), + language: UserLanguageSchema.optional(), + name: z + .string() + .min(1) + .refine((value) => value === value.trim(), { + message: 'Name must not start or end with whitespace', + }) + .optional(), + currentPassword: z.string().min(1).optional().meta({ + description: 'Current password, required when setting a new password', + example: 'currentPassword123', + format: 'password', + }), + newPassword: z + .string() + .min(8, 'New password should have at least 8 characters') + .optional() + .meta({ + description: 'New password', + example: 'newPassword456', + format: 'password', + }), + }) + .superRefine((data, ctx) => { + if (data.email && data.newPassword) { + ctx.addIssue({ + code: 'custom', + message: + 'You cannot change your email address and password in the same request.', + }) + return + } + + if (data.newPassword && !data.currentPassword) { + ctx.addIssue({ + code: 'custom', + path: ['currentPassword'], + message: messages.currentPasswordRequired, + }) + } + }) + .meta({ + id: 'UpdateCurrentUserRequest', + description: 'Payload for updating the authenticated user profile.', + }) + +const UpdateCurrentUserSuccessResponseSchema = z + .object({ + code: z.literal('Ok').default('Ok'), + message: z.string().meta({ + example: 'User successfully saved. Password updated.', + }), + data: z.object({ + me: UserSchema, + }), + }) + .meta({ + id: 'UpdateCurrentUserSuccessResponse', + description: 'Profile updated successfully.', + }) + +const UpdateCurrentUserNoChangesResponseSchema = z + .object({ + code: z.literal('Ok').default('Ok'), + message: z.literal(messages.noChanges).default(messages.noChanges), + }) + .meta({ + id: 'UpdateCurrentUserNoChangesResponse', + description: 'No profile changes were applied.', + }) + +const UpdateCurrentUserResponseSchema = z + .union([ + UpdateCurrentUserSuccessResponseSchema, + UpdateCurrentUserNoChangesResponseSchema, + ]) + .meta({ + id: 'UpdateCurrentUserResponse', + description: + 'Response returned after updating the current user. If no changed properties are supplied, a no-changes response is returned.', + }) + +const DeleteCurrentUserRequestSchema = z + .object({ + password: z.string().min(1, messages.badRequest).meta({ + description: 'Current password for account deletion confirmation', + example: 'myCurrentPassword123', + format: 'password', + }), + }) + .meta({ + id: 'DeleteCurrentUserRequest', + description: 'Payload for deleting the authenticated user account.', + }) + +export const openapi: ZodOpenApiPathItemObject = { + get: { + tags: ['User Management'], + summary: 'Get current user profile', + description: + "Retrieves the authenticated user's profile information, including the ids of the user's devices.", + operationId: 'getCurrentUser', + security: [{ bearerAuth: [] }], + + responses: { + 200: { + description: 'Successfully retrieved user profile.', + content: { + 'application/json': { + schema: GetMeResponseSchema, + }, + }, + }, + + 403: forbiddenResponse( + ForbiddenErrorSchema, + 'Invalid or missing JWT authorization.', + ), + + 500: internalServerErrorResponse( + InternalServerErrorSchema, + 'Internal server error.', + ), + }, + }, + + put: { + tags: ['User Management'], + summary: 'Update current user profile', + description: + "Updates the authenticated user's profile information. To change the password, `currentPassword` must be supplied together with `newPassword`.", + operationId: 'updateCurrentUser', + security: [{ bearerAuth: [] }], + + requestBody: { + required: true, + content: { + 'application/json': { + schema: UpdateCurrentUserRequestSchema, + }, + }, + }, + + responses: { + 200: { + description: + 'User profile updated successfully, or no changed properties were supplied.', + content: { + 'application/json': { + schema: UpdateCurrentUserResponseSchema, + }, + }, + }, + + 400: badRequestResponse( + BadRequestErrorSchema, + 'Bad request. This can happen for invalid JSON, invalid request data, missing current password for password changes, or rejected update data.', + ), + + 403: forbiddenResponse( + ForbiddenErrorSchema, + 'Invalid or missing JWT authorization.', + ), + + 405: methodNotAllowedResponse( + MethodNotAllowedErrorSchema, + 'Method not allowed.', + ), + + 500: internalServerErrorResponse( + InternalServerErrorSchema, + 'Internal server error.', + ), + }, + }, + + delete: { + tags: ['User Management'], + summary: 'Delete current user account', + description: + "Permanently deletes the authenticated user's account. The current password must be supplied as form data.", + operationId: 'deleteCurrentUser', + security: [{ bearerAuth: [] }], + + requestBody: { + required: true, + content: { + 'application/x-www-form-urlencoded': { + schema: DeleteCurrentUserRequestSchema, + }, + }, + }, + + responses: { + 200: { + description: 'Account successfully deleted.', + content: { + 'application/json': { + schema: z.null().meta({ + description: + 'JSON null response indicating successful account deletion.', + }), + }, + }, + }, + + 400: badRequestResponse( + BadRequestErrorSchema, + 'Bad request. This can happen when the form body cannot be parsed or the password is missing.', + ), + + 401: unauthorizedResponse( + UnauthorizedErrorSchema, + 'Unauthorized. The provided password is incorrect.', + ), + + 403: forbiddenResponse( + ForbiddenErrorSchema, + 'Invalid or missing JWT authorization.', + ), + + 405: methodNotAllowedResponse( + MethodNotAllowedErrorSchema, + 'Method not allowed.', + ), + + 500: internalServerErrorResponse( + InternalServerErrorSchema, + 'Internal server error.', + ), + }, + }, +} + +const getBearerToken = (request: Request) => { + const rawAuthorizationHeader = request.headers.get('authorization') + if (!rawAuthorizationHeader) return undefined + + const [scheme, token] = rawAuthorizationHeader.split(' ') + if (scheme?.toLowerCase() !== 'bearer' || !token) return undefined + + return token +} + +export const middleware: Route.MiddlewareFunction[] = [ + requestContentTypeJson(['PUT']), + requestContentTypeJsonOrForm(['DELETE']), +] -/** - * @openapi - * /api/users/me: - * get: - * tags: - * - User Management - * summary: Get current user profile - * description: Retrieves the authenticated user's profile information - * operationId: getCurrentUser - * security: - * - bearerAuth: [] - * responses: - * 200: - * description: Successfully retrieved user profile - * content: - * application/json: - * schema: - * type: object - * properties: - * code: - * type: string - * example: Ok - * data: - * type: object - * properties: - * me: - * $ref: '#/components/schemas/User' - * 403: - * description: Invalid or missing JWT token - * content: - * application/json: - * schema: - * type: object - * properties: - * code: - * type: string - * example: Forbidden - * message: - * type: string - * example: Invalid JWT authorization. Please sign in to obtain new JWT. - * 500: - * description: Internal server error - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: Internal Server Error - * message: - * type: string - * example: The server was unable to complete your request. Please try again later. - * put: - * tags: - * - User Management - * summary: Update user profile - * description: Updates the authenticated user's profile information - * operationId: updateUserProfile - * security: - * - bearerAuth: [] - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * properties: - * email: - * type: string - * format: email - * description: New email address - * example: newemail@example.com - * language: - * type: string - * description: Preferred language setting - * example: en - * name: - * type: string - * description: User's display name - * example: John Doe - * currentPassword: - * type: string - * format: password - * description: Current password (required for password change) - * example: currentPassword123 - * newPassword: - * type: string - * format: password - * description: New password - * example: newPassword456 - * responses: - * 200: - * description: User profile updated successfully or no changes made - * content: - * application/json: - * schema: - * oneOf: - * - type: object - * description: Profile updated successfully - * properties: - * code: - * type: string - * example: Ok - * message: - * type: string - * example: User successfully saved. Password updated. - * data: - * type: object - * properties: - * me: - * $ref: '#/components/schemas/User' - * - type: object - * description: No changes made - * properties: - * code: - * type: string - * example: Ok - * message: - * type: string - * example: No changed properties supplied. User remains unchanged. - * 400: - * description: Bad request - validation errors - * content: - * application/json: - * schema: - * type: object - * properties: - * code: - * type: string - * example: Bad Request - * message: - * type: string - * example: Current password is incorrect - * 403: - * description: Invalid or missing JWT token - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/ForbiddenError' - * 500: - * description: Internal server error - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/InternalServerError' - * delete: - * tags: - * - User Management - * summary: Delete user account - * description: Permanently deletes the authenticated user's account - * operationId: deleteUserAccount - * security: - * - bearerAuth: [] - * requestBody: - * required: true - * content: - * application/x-www-form-urlencoded: - * schema: - * type: object - * required: - * - password - * properties: - * password: - * type: string - * format: password - * description: Current password for account deletion confirmation - * example: myCurrentPassword123 - * responses: - * 200: - * description: Account successfully deleted - * content: - * application/json: - * schema: - * type: "null" - * description: Empty response indicating successful deletion - * 400: - * description: Bad request - missing password - * content: - * text/plain: - * schema: - * type: string - * example: Bad Request - * 401: - * description: Unauthorized - incorrect password - * content: - * application/json: - * schema: - * type: object - * properties: - * message: - * type: string - * example: Password incorrect - * 403: - * description: Invalid or missing JWT token - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/ForbiddenError' - * 500: - * description: Internal server error - * content: - * text/plain: - * schema: - * type: string - * example: Internal Server Error - * components: - * securitySchemes: - * bearerAuth: - * type: http - * scheme: bearer - * bearerFormat: JWT - * description: JWT token obtained from sign-in endpoint - * schemas: - * User: - * type: object - * description: User profile information - * properties: - * id: - * type: string - * description: Unique user identifier - * example: user_123456 - * email: - * type: string - * format: email - * description: User's email address - * example: user@example.com - * name: - * type: string - * description: User's display name - * example: John Doe - * language: - * type: string - * description: User's preferred language - * example: en - * createdAt: - * type: string - * format: date-time - * description: Account creation timestamp - * example: 2024-01-15T10:30:00Z - * updatedAt: - * type: string - * format: date-time - * description: Last account update timestamp - * example: 2024-01-20T14:45:00Z - * ForbiddenError: - * type: object - * properties: - * code: - * type: string - * example: Forbidden - * message: - * type: string - * example: Invalid JWT authorization. Please sign in to obtain new JWT. - * InternalServerError: - * type: object - * properties: - * error: - * type: string - * example: Internal Server Error - * message: - * type: string - * example: The server was unable to complete your request. Please try again later. - */ export const loader = async ({ request }: Route.LoaderArgs) => { try { - const jwtResponse = await getUserFromJwt(request) + const user = await getAuthenticatedUser(request) - if (typeof jwtResponse === 'string') - return StandardResponse.forbidden( - 'Invalid JWT authorization. Please sign in to obtain new JWT.', - ) + if (user instanceof Response) { + return user + } - const deviceIds = await getUserDeviceIds(jwtResponse.id) + const deviceIds = await getUserDeviceIds(user.id) - return StandardResponse.ok({ + const responseParsed = await GetMeResponseSchema.safeParseAsync({ code: 'Ok', - data: { me: { ...jwtResponse, boxes: deviceIds } }, + data: { + me: { + ...transformUserToApiFormat(user), + boxes: deviceIds, + }, + }, }) + + if (!responseParsed.success) { + console.error(responseParsed.error.issues) + return StandardResponse.internalServerError() + } + + return StandardResponse.ok(responseParsed.data) } catch (err) { console.warn(err) return StandardResponse.internalServerError() @@ -291,93 +339,109 @@ export const loader = async ({ request }: Route.LoaderArgs) => { } export const action = async ({ request }: Route.ActionArgs) => { - const loaderValue = (await loader({ - request, - } as Route.LoaderArgs)) as Response - if (loaderValue.status !== 200) return loaderValue + const user = await getAuthenticatedUser(request) - const user = (await loaderValue.json()).data.me as User + if (user instanceof Response) { + return user + } switch (request.method) { case 'PUT': return await put(user, request) + case 'DELETE': return await del(user, request) + default: return StandardResponse.methodNotAllowed('Method Not Allowed') } } const put = async (user: User, request: Request): Promise => { - const { email, language, name, currentPassword, newPassword } = - await request.json() try { - const rawAuthorizationHeader = request.headers.get('authorization') - if (!rawAuthorizationHeader) throw new Error('no_token') - const [, jwtString] = rawAuthorizationHeader.split(' ') - - const { updated, messages, updatedUser } = await updateUserDetails( - user, - jwtString, - { - email, - language, - name, - currentPassword, - newPassword, - }, + const requestData = await parseJsonBody( + request, + UpdateCurrentUserRequestSchema, ) - const messageText = messages.join('.') - if (updated === false) { - if (messages.length > 0) { + if (requestData instanceof Response) { + return requestData + } + + const jwtString = getBearerToken(request) + + if (!jwtString) { + return StandardResponse.forbidden(apiMessages.invalidJwt) + } + + const { + updated, + messages: updateMessages, + updatedUser, + } = await updateUserDetails(user, jwtString, requestData) + + const messageText = updateMessages.join('.') + + if (updated === false || updateMessages.length === 0) { + if (updated === false && updateMessages.length > 0) { return StandardResponse.badRequest(messageText) } - return StandardResponse.ok({ + const responseParsed = + await UpdateCurrentUserNoChangesResponseSchema.safeParseAsync({ + code: 'Ok', + message: messages.noChanges, + }) + + if (!responseParsed.success) { + console.warn(responseParsed.error) + return StandardResponse.internalServerError() + } + + return StandardResponse.ok(responseParsed.data) + } + + const responseParsed = + await UpdateCurrentUserSuccessResponseSchema.safeParseAsync({ code: 'Ok', - message: 'No changed properties supplied. User remains unchanged.', + message: `User successfully saved. ${messageText}`, + data: { me: transformUserToApiFormat(updatedUser) }, }) + + if (!responseParsed.success) { + console.warn(responseParsed.error) + return StandardResponse.internalServerError() } - return StandardResponse.ok({ - code: 'Ok', - message: `User successfully saved. ${messageText}`, - data: { me: updatedUser }, - }) + return StandardResponse.ok(responseParsed.data) } catch (err) { console.warn(err) return StandardResponse.internalServerError() } } -const del = async (user: User, r: Request): Promise => { +const del = async (user: User, request: Request): Promise => { try { - let formData = new FormData() - try { - formData = await r.formData() - } catch { - // Just continue, it will fail in the next check + const requestData = await parseFormRequest( + request, + DeleteCurrentUserRequestSchema, + ) + + if (requestData instanceof Response) { + return requestData } - if ( - !formData.has('password') || - formData.get('password')?.toString().length === 0 - ) - return StandardResponse.badRequest('Bad Request') + const jwtString = getBearerToken(request) - const rawAuthorizationHeader = r.headers.get('authorization') - if (!rawAuthorizationHeader) throw new Error('no_token') - const [, jwtString] = rawAuthorizationHeader.split(' ') + if (!jwtString) { + return StandardResponse.forbidden(apiMessages.invalidJwt) + } - const deleted = await deleteUser( - user, - formData.get('password')!.toString(), // ! operator is fine, we check formData.has above - jwtString, - ) + const deleted = await deleteUser(user, requestData.password, jwtString) - if (deleted === 'unauthorized') - return StandardResponse.unauthorized('Password incorrect') + if (deleted === 'unauthorized') { + return StandardResponse.unauthorized(apiMessages.passwordIncorrect) + } return StandardResponse.ok(null) } catch (err) { diff --git a/app/routes/api.users.password-reset.ts b/app/routes/api.users.password-reset.ts index d24863493..5a2394789 100644 --- a/app/routes/api.users.password-reset.ts +++ b/app/routes/api.users.password-reset.ts @@ -2,52 +2,201 @@ import { type Route } from './+types/api.users.password-reset' import { StandardResponse } from '~/lib/responses' import { resetPassword } from '~/services/user-service.server' -export const action = async ({ request }: Route.ActionArgs) => { - let formData = new FormData() +import * as z from 'zod/v4' +import 'zod-openapi' +import { type ZodOpenApiPathItemObject } from 'zod-openapi' + +import { + InternalServerErrorSchema, + createBadRequestErrorSchema, + createForbiddenErrorSchema, +} from '~/lib/openapi/errors' + +import { + badRequestResponse, + forbiddenResponse, + internalServerErrorResponse, +} from '~/lib/openapi/errors' + +const PasswordResetRequestSchema = z + .object({ + password: z + .string({ + error: 'No new password specified.', + }) + .min(1, { + error: 'No new password specified.', + }) + .min(8, { + error: 'Password must be at least 8 characters.', + }) + .meta({ + description: 'New password. Must be at least 8 characters long.', + example: 'newPassword456', + format: 'password', + }), + + token: z + .string({ + error: 'No password reset token specified.', + }) + .trim() + .min(1, { + error: 'No password reset token specified.', + }) + .meta({ + description: 'Password reset token sent to the user by email.', + example: 'pwreset_01jv7c9x8n0example', + }), + }) + .meta({ + id: 'PasswordResetRequest', + description: 'Payload for resetting a password using an email token.', + }) + +const PasswordResetResponseSchema = z + .object({ + code: z.literal('Ok').default('Ok'), + message: z + .literal( + 'Password successfully changed. You can now login with your new password', + ) + .default( + 'Password successfully changed. You can now login with your new password', + ), + }) + .meta({ + id: 'PasswordResetResponse', + description: 'Password reset success response.', + }) + +const PasswordResetBadRequestErrorSchema = createBadRequestErrorSchema({ + id: 'PasswordResetBadRequestError', + description: + 'Bad request. This can happen when the password or token is missing, or when the new password does not meet the password requirements.', + examples: [ + 'No new password specified.', + 'No password reset token specified.', + 'Password must be at least 8 characters.', + ], +}) + +const PasswordResetForbiddenErrorSchema = createForbiddenErrorSchema({ + id: 'PasswordResetForbiddenError', + description: + 'Returned when the password reset token is invalid, expired, or password reset is not possible for this user.', + examples: [ + 'Password reset for this user not possible', + 'Password reset token expired', + ], +}) + +export const openapi: ZodOpenApiPathItemObject = { + post: { + tags: ['User Management'], + summary: 'Reset password', + description: + 'Resets a user password using a password reset token sent by email. The token is valid for the configured password-reset lifetime.', + operationId: 'resetPassword', + + requestBody: { + required: true, + content: { + 'application/x-www-form-urlencoded': { + schema: PasswordResetRequestSchema, + }, + 'multipart/form-data': { + schema: PasswordResetRequestSchema, + }, + }, + }, + + responses: { + 200: { + description: 'Password changed successfully.', + content: { + 'application/json': { + schema: PasswordResetResponseSchema, + }, + }, + }, + + 400: badRequestResponse( + PasswordResetBadRequestErrorSchema, + 'Bad request. The password or token is missing, or the password format is invalid.', + ), + + 403: forbiddenResponse( + PasswordResetForbiddenErrorSchema, + 'Password reset is not possible because the token is invalid or expired.', + ), + + 500: internalServerErrorResponse( + InternalServerErrorSchema, + 'Internal server error.', + ), + }, + }, +} + +const parsePasswordResetRequest = async ( + request: Request, +): Promise | Response> => { + let formData: FormData + try { formData = await request.formData() } catch { - // Just continue, it will fail in the next check - // The try catch block handles an exception that occurs if the - // request was sent without x-www-form-urlencoded content-type header + return StandardResponse.badRequest('Bad Request') } - if ( - !formData.has('password') || - formData.get('password')?.toString().trim().length === 0 + const parsed = await PasswordResetRequestSchema.safeParseAsync( + Object.fromEntries(formData.entries()), ) - return StandardResponse.badRequest('No new password specified.') - if ( - !formData.has('token') || - formData.get('token')?.toString().trim().length === 0 - ) - return StandardResponse.badRequest('No password reset token specified.') + if (!parsed.success) { + return StandardResponse.badRequest( + parsed.error.issues[0]?.message ?? 'Bad Request', + ) + } + + return parsed.data +} + +export const action = async ({ request }: Route.ActionArgs) => { + const requestParsed = await parsePasswordResetRequest(request) + + if (requestParsed instanceof Response) { + return requestParsed + } try { const resetStatus = await resetPassword( - formData.get('token')!.toString(), - formData.get('password')!.toString(), + requestParsed.token, + requestParsed.password, ) switch (resetStatus) { case 'forbidden': - case 'expired': return StandardResponse.forbidden( - resetStatus === 'forbidden' - ? 'Password reset for this user not possible' - : 'Password reset token expired', + 'Password reset for this user not possible', ) + + case 'expired': + return StandardResponse.forbidden('Password reset token expired') + case 'invalid_password_format': return StandardResponse.badRequest( - 'Password must be at least ${password_min_length} characters.', + 'Password must be at least 8 characters.', ) + case 'success': - return StandardResponse.ok({ + return await PasswordResetResponseSchema.safeParseAsync({ code: 'Ok', - message: - 'Password successfully changed. You can now login with your new password', }) + + default: + return StandardResponse.internalServerError() } } catch (err) { console.warn(err) diff --git a/app/routes/api.users.refresh-auth.ts b/app/routes/api.users.refresh-auth.ts index f827f6d5c..6cdb31f04 100644 --- a/app/routes/api.users.refresh-auth.ts +++ b/app/routes/api.users.refresh-auth.ts @@ -1,161 +1,170 @@ import { type Route } from './+types/api.users.refresh-auth' -import { type User } from '~/db/schema' import { getUserFromJwt, hashJwt, refreshJwt } from '~/lib/jwt' -import { parseRefreshTokenData } from '~/lib/request-parsing' import { StandardResponse } from '~/lib/responses' +import { z } from 'zod' +import { type ZodOpenApiPathItemObject } from 'zod-openapi' +import { + createForbiddenErrorSchema, + forbiddenResponse, + internalServerErrorResponse, + InternalServerErrorSchema, +} from '~/lib/openapi/errors' +import { UserSchema } from '~/lib/openapi/schemas/user' +import { transformUserToApiFormat } from '~/lib/user-transform' +import { parseBearerToken, parseRefreshAuthBody } from '~/lib/request-parsing' -/** - * @openapi - * /api/users/refresh-auth: - * post: - * tags: - * - Authentication - * summary: Refresh authentication token - * description: Refreshes a JWT access token using a valid refresh token - * operationId: refreshAuth - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * required: - * - token - * properties: - * token: - * type: string - * description: Valid refresh token - * example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." - * application/x-www-form-urlencoded: - * schema: - * type: object - * required: - * - token - * properties: - * token: - * type: string - * description: Valid refresh token - * example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." - * responses: - * 200: - * description: Successfully refreshed authentication - * content: - * application/json: - * schema: - * type: object - * properties: - * code: - * type: string - * example: Authorized - * message: - * type: string - * example: Successfully refreshed auth - * data: - * type: object - * properties: - * user: - * $ref: '#/components/schemas/User' - * token: - * type: string - * description: New JWT access token - * example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... - * refreshToken: - * type: string - * description: New JWT refresh token - * example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... - * 403: - * description: Authentication failed - invalid or expired refresh token - * content: - * application/json: - * schema: - * type: object - * properties: - * code: - * type: string - * example: Forbidden - * message: - * type: string - * enum: - * - You must specify a token to refresh - * - Refresh token invalid or too old. Please sign in with your username and password. - * 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 - */ +const errorMessages = { + tokenRequired: 'You must specify a token to refresh', + refreshTokenInvalid: + 'Refresh token invalid or too old. Please sign in with your username and password.', +} + +export const RefreshAuthRequestSchema = z + .object({ + token: z + .string() + .trim() + .min(1, { + error: errorMessages.tokenRequired, + }) + .meta({ + description: + 'Refresh token bound to the current access token. This value is compared to the hash of the supplied bearer token.', + example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', + }), + }) + .meta({ + id: 'RefreshAuthRequest', + description: + 'Refresh authentication request body. Can be submitted as JSON or form data.', + }) + +const JwtTokenSchema = z.jwt({ alg: 'HS256' }).meta({ + description: 'JWT access token', + example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', +}) + +const RefreshAuthResponseSchema = z + .object({ + code: z.literal('Authorized').default('Authorized'), + message: z + .literal('Successfully refreshed auth') + .default('Successfully refreshed auth'), + data: z.object({ + user: UserSchema, + }), + token: JwtTokenSchema.meta({ + description: 'New JWT access token', + }), + refreshToken: z.string().meta({ + description: 'New refresh token', + example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', + }), + }) + .meta({ + id: 'RefreshAuthResponse', + description: 'Successfully refreshed authentication response.', + }) + +const RefreshAuthForbiddenErrorSchema = createForbiddenErrorSchema({ + id: 'RefreshAuthForbiddenError', + description: + 'Authentication failed because the refresh token is missing, invalid, expired, or the request body could not be parsed.', + messageSchema: z.union([ + z.literal(errorMessages.tokenRequired), + z.literal(errorMessages.refreshTokenInvalid), + z.string().startsWith('Invalid request format:').meta({ + example: + 'Invalid request format: Failed to parse request body as JSON or form data', + }), + ]), +}) + +export const openapi: ZodOpenApiPathItemObject = { + post: { + tags: ['Authentication'], + summary: 'Refresh authentication token', + description: + 'Refreshes the JWT access token using a valid refresh token. The current access token must be supplied via the Authorization bearer header, and the refresh token must be supplied in the request body.', + operationId: 'refreshAuth', + security: [{ bearerAuth: [] }], + + requestBody: { + required: true, + content: { + 'application/json': { + schema: RefreshAuthRequestSchema, + }, + 'application/x-www-form-urlencoded': { + schema: RefreshAuthRequestSchema, + }, + }, + }, + + responses: { + 200: { + description: 'Successfully refreshed authentication.', + content: { + 'application/json': { + schema: RefreshAuthResponseSchema, + }, + }, + }, + + 403: forbiddenResponse( + RefreshAuthForbiddenErrorSchema, + 'Authentication failed. The refresh token is missing, invalid, expired, or the request body could not be parsed.', + ), + + 500: internalServerErrorResponse( + InternalServerErrorSchema, + 'Internal server error.', + ), + }, + }, +} export const action = async ({ request }: Route.ActionArgs) => { try { - // Parse request data - handles both JSON and form data automatically - const data = await parseRefreshTokenData(request) - - if (!data.token || data.token.trim().length === 0) - return StandardResponse.forbidden('You must specify a token to refresh') - - // We deliberately make casts and stuff like that, so everything - // but the happy path will result in an internal server error. - // This is done s.t. we are not leaking information if someone - // tries sending random token to see if users exist or similar - const user = (await getUserFromJwt(request)) as User - const rawAuthorizationHeader = request.headers - .get('authorization')! - .toString() - const [, jwtString = ''] = rawAuthorizationHeader.split(' ') - - if (data.token !== hashJwt(jwtString)) - return StandardResponse.forbidden( - 'Refresh token invalid or too old. Please sign in with your username and password.', - ) - - const { token, refreshToken } = (await refreshJwt(user, data.token)) || {} - - if (token && refreshToken) - return StandardResponse.ok({ - code: 'Authorized', - message: 'Successfully refreshed auth', - data: { user }, - token, - refreshToken, - }) - else - return StandardResponse.forbidden( - 'Refresh token invalid or too old. Please sign in with your username and password.', - ) - } catch (error) { - // Handle parsing errors - if (error instanceof Error && error.message.includes('Failed to parse')) - return StandardResponse.forbidden( - `Invalid request format: ${error.message}`, - ) + const body = await parseRefreshAuthBody(request) + if (body instanceof Response) return body + + const jwtString = parseBearerToken(request) + if (jwtString instanceof Response) return jwtString + + const jwtResponse = await getUserFromJwt(request) + + if (typeof jwtResponse === 'string') { + return StandardResponse.forbidden(errorMessages.refreshTokenInvalid) + } + + if (body.token !== hashJwt(jwtString)) { + return StandardResponse.forbidden(errorMessages.refreshTokenInvalid) + } - // Handle other errors + const refreshed = await refreshJwt(jwtResponse, body.token) + + if (!refreshed?.token || !refreshed.refreshToken) { + return StandardResponse.forbidden(errorMessages.refreshTokenInvalid) + } + + const responseParsed = await RefreshAuthResponseSchema.safeParseAsync({ + code: 'Authorized', + message: 'Successfully refreshed auth', + data: { + user: transformUserToApiFormat(jwtResponse), + }, + token: refreshed.token, + refreshToken: refreshed.refreshToken, + }) + + if (!responseParsed.success) { + console.warn(responseParsed.error) + return StandardResponse.internalServerError() + } + + return StandardResponse.ok(responseParsed.data) + } catch (error) { console.warn(error) return StandardResponse.internalServerError() } diff --git a/app/routes/api.users.register.ts b/app/routes/api.users.register.ts index 6218f9085..55852ca4a 100644 --- a/app/routes/api.users.register.ts +++ b/app/routes/api.users.register.ts @@ -4,6 +4,204 @@ import { parseUserRegistrationData } from '~/lib/request-parsing' import { StandardResponse } from '~/lib/responses' import { registerUser } from '~/services/user-service.server' +import * as z from 'zod/v4' +import { type ZodOpenApiPathItemObject } from 'zod-openapi' + +import { UserSchema, UserLanguageSchema } from '~/lib/openapi/schemas/user' + +import { + BadRequestErrorSchema, + InternalServerErrorSchema, + MethodNotAllowedErrorSchema, +} from '~/lib/openapi/errors' + +import { + badRequestResponse, + internalServerErrorResponse, + methodNotAllowedResponse, +} from '~/lib/openapi/errors' +import { + requestContentTypeJsonOrForm, + responseContentTypeJson, +} from '~/middleware/content-type-header.server' + +const RegistrationNameSchema = z + .string() + .trim() + .min(3) + .max(40) + .regex(/^[a-zA-Z0-9](?:[a-zA-Z0-9._ -]*[a-zA-Z0-9])?$/) + .meta({ + description: + 'Full name or nickname of the user. Must be 3 to 40 characters long. Allows letters, numbers, dots, dashes, underscores, and spaces. The first and last character must be a letter or number.', + example: 'Jane Doe', + }) + +const RegisterUserRequestSchema = z + .object({ + name: RegistrationNameSchema, + + email: z.string().trim().pipe(z.email()).meta({ + description: 'Email address used for signing in and user-related emails.', + example: 'jane@example.com', + }), + + password: z.string().min(8).meta({ + description: 'Desired password. Must be at least 8 characters long.', + example: 'correct-horse-battery-staple', + format: 'password', + }), + + language: UserLanguageSchema.optional().default('en_US').meta({ + description: + 'Preferred user language. Used for the website and emails. Defaults to `en_US`.', + example: 'en_US', + }), + }) + .meta({ + id: 'RegisterUserRequest', + description: 'Payload for registering a new user.', + }) + +const RegisterUserResponseSchema = z + .object({ + code: z.literal('Created').default('Created'), + + message: z + .literal('Successfully registered new user') + .default('Successfully registered new user'), + + token: z.jwt({ alg: 'HS256' }).meta({ + description: 'JWT access token', + example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', + }), + + refreshToken: z.string().meta({ + description: 'Refresh token', + example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', + }), + + data: z.object({ + user: UserSchema, + }), + }) + .meta({ + id: 'RegisterUserResponse', + description: 'Successfully registered user response.', + }) + +const RegisterUserBadRequestErrorSchema = BadRequestErrorSchema.meta({ + id: 'RegisterUserBadRequestError', + description: + 'Bad request. This can happen when the request body cannot be parsed or the submitted registration data is invalid.', + examples: [ + { + code: 'Bad Request', + message: 'Username is required.', + error: 'Username is required.', + }, + { + code: 'Bad Request', + message: + 'Username must be at least 3 characters long and not more than 40.', + error: + 'Username must be at least 3 characters long and not more than 40.', + }, + { + code: 'Bad Request', + message: + 'Username may only contain alphanumeric characters or single hyphens, and cannot begin or end with a hyphen.', + error: + 'Username may only contain alphanumeric characters or single hyphens, and cannot begin or end with a hyphen.', + }, + { + code: 'Bad Request', + message: 'Username is already taken.', + error: 'Username is already taken.', + }, + { + code: 'Bad Request', + message: 'Email is required.', + error: 'Email is required.', + }, + { + code: 'Bad Request', + message: 'Invalid email format.', + error: 'Invalid email format.', + }, + { + code: 'Bad Request', + message: 'User already exists.', + error: 'User already exists.', + }, + { + code: 'Bad Request', + message: 'Password is required.', + error: 'Password is required.', + }, + { + code: 'Bad Request', + message: 'Password must be at least 8 characters long.', + error: 'Password must be at least 8 characters long.', + }, + { + code: 'Bad Request', + message: + 'Invalid request format: Failed to parse request body as JSON or form data', + error: + 'Invalid request format: Failed to parse request body as JSON or form data', + }, + ], +}) + +export const openapi: ZodOpenApiPathItemObject = { + post: { + tags: ['Authentication'], + summary: 'Register a new user', + description: + 'Registers a new openSenseMap user and returns an access token and refresh token. The user can sign in with the registered email address.', + operationId: 'registerUser', + + requestBody: { + required: true, + content: { + 'application/json': { + schema: RegisterUserRequestSchema, + }, + 'application/x-www-form-urlencoded': { + schema: RegisterUserRequestSchema, + }, + }, + }, + + responses: { + 201: { + description: 'User registered successfully.', + content: { + 'application/json': { + schema: RegisterUserResponseSchema, + }, + }, + }, + + 400: badRequestResponse( + RegisterUserBadRequestErrorSchema, + 'Bad request. This can happen when the request body cannot be parsed or the submitted registration data is invalid.', + ), + + 405: methodNotAllowedResponse( + MethodNotAllowedErrorSchema, + 'Method not allowed.', + ), + + 500: internalServerErrorResponse( + InternalServerErrorSchema, + 'Internal server error.', + ), + }, + }, +} + function mapRegistrationError(code: string): string { switch (code) { case 'username_required': @@ -30,6 +228,11 @@ function mapRegistrationError(code: string): string { } } +export const middleware: Route.MiddlewareFunction[] = [ + requestContentTypeJsonOrForm(['POST']), + responseContentTypeJson, +] + export const action = async ({ request }: Route.ActionArgs) => { if (request.method !== 'POST') { return StandardResponse.methodNotAllowed('') diff --git a/app/routes/api.users.request-password-reset.ts b/app/routes/api.users.request-password-reset.ts index e821873a8..474adeecc 100644 --- a/app/routes/api.users.request-password-reset.ts +++ b/app/routes/api.users.request-password-reset.ts @@ -2,33 +2,199 @@ import { type Route } from './+types/api.users.request-password-reset' import { StandardResponse } from '~/lib/responses' import { requestPasswordReset } from '~/services/user-service.server' -export const action = async ({ request }: Route.ActionArgs) => { - let formData = new FormData() +import * as z from 'zod/v4' +import 'zod-openapi' +import { type ZodOpenApiPathItemObject } from 'zod-openapi' + +import { + BadRequestErrorSchema, + InternalServerErrorSchema, +} from '~/lib/openapi/errors' + +import { + badRequestResponse, + internalServerErrorResponse, +} from '~/lib/openapi/errors' +import { requestContentTypeForm } from '~/middleware/content-type-header.server' + +const RequestPasswordResetRequestSchema = z + .object({ + email: z + .string({ + error: 'No email address specified.', + }) + .trim() + .min(1, { + error: 'No email address specified.', + }) + .pipe( + z.email({ + error: 'Invalid email address.', + }), + ) + .meta({ + description: 'Email address of the user requesting a password reset.', + example: 'user@example.com', + }), + }) + .meta({ + id: 'RequestPasswordResetRequest', + description: 'Payload for requesting a password reset.', + }) + +const RequestPasswordResetResponseSchema = z + .object({ + code: z.literal('Ok').default('Ok'), + message: z + .literal('Password reset initiated') + .default('Password reset initiated'), + }) + .meta({ + id: 'RequestPasswordResetResponse', + description: + 'Password reset initiation response. This response is returned regardless of whether the email address belongs to an existing user.', + }) + +export const openapi: ZodOpenApiPathItemObject = { + post: { + tags: ['User Management'], + summary: 'Request a password reset', + description: + 'Requests a password reset for the given email address. If the email address belongs to a user, an email with reset instructions is sent. To avoid leaking whether an email address exists, a successful response is returned regardless of whether the address is known.', + operationId: 'requestPasswordReset', + + requestBody: { + required: true, + content: { + 'application/x-www-form-urlencoded': { + schema: RequestPasswordResetRequestSchema, + }, + 'multipart/form-data': { + schema: RequestPasswordResetRequestSchema, + }, + }, + }, + + responses: { + 200: { + description: 'Password reset initiated.', + content: { + 'application/json': { + schema: RequestPasswordResetResponseSchema, + }, + }, + }, + + 400: badRequestResponse( + BadRequestErrorSchema, + 'Bad request. The email field is missing or empty.', + ), + + 500: internalServerErrorResponse( + InternalServerErrorSchema, + 'Internal server error.', + ), + }, + }, +} + +const parsePasswordResetRequest = async ( + request: Request, +): Promise | Response> => { + let formData: FormData + try { formData = await request.formData() } catch { - // Just continue, it will fail in the next check - // The try catch block handles an exception that occurs if the - // request was sent without x-www-form-urlencoded content-type header + return StandardResponse.badRequest('Invalid form data.') } - if ( - !formData.has('email') || - formData.get('email')?.toString().trim().length === 0 + const parsed = await RequestPasswordResetRequestSchema.safeParseAsync( + Object.fromEntries(formData.entries()), ) - return StandardResponse.badRequest('No email address specified.') + + if (!parsed.success) { + const issue = parsed.error.issues[0] + + if (issue?.path.includes('email')) { + return StandardResponse.badRequest( + issue.code === 'invalid_format' + ? 'Invalid email address.' + : 'No email address specified.', + ) + } + + return StandardResponse.badRequest( + issue?.message ?? 'Invalid password reset request.', + ) + } + + return parsed.data +} + +export const middleware: Route.MiddlewareFunction[] = [requestContentTypeForm] + +export const action = async ({ request }: Route.ActionArgs) => { + const parsedRequest = await parsePasswordResetRequest(request) + + if (parsedRequest instanceof Response) { + return parsedRequest + } try { - await requestPasswordReset(formData.get('email')!.toString()) - - // We don't want to leak valid/ invalid emails, so we confirm - // the initiation no matter what the return value above is - return StandardResponse.ok({ - code: 'Ok', - message: 'Password reset initiated', - }) + await requestPasswordReset(parsedRequest.email) + + const responseParsed = + await RequestPasswordResetResponseSchema.safeParseAsync({ + code: 'Ok', + message: 'Password reset initiated', + }) + + if (!responseParsed.success) { + console.warn(responseParsed.error) + return StandardResponse.internalServerError() + } + + return StandardResponse.ok(responseParsed.data) } catch (err) { console.warn(err) return StandardResponse.internalServerError() } } + +// export const action = async ({ request }: Route.ActionArgs) => { +// let formData = new FormData() +// try { +// formData = await request.formData() +// } catch { +// // Just continue, it will fail in the next check +// // The try catch block handles an exception that occurs if the +// // request was sent without x-www-form-urlencoded content-type header +// } + +// const email = formData.get('email')?.toString().trim() + +// if (!email) { +// return StandardResponse.badRequest('No email address specified.') +// } + +// const parsedEmail = z.email().safeParse(email) + +// if (!parsedEmail.success) { +// return StandardResponse.badRequest('Invalid email address.') +// } + +// try { +// await requestPasswordReset(parsedEmail.data) + +// // We don't want to leak valid/ invalid emails, so we confirm +// // the initiation no matter what the return value above is +// return StandardResponse.ok({ +// code: 'Ok', +// message: 'Password reset initiated', +// }) +// } catch (err) { +// console.warn(err) +// return StandardResponse.internalServerError() +// } +// } diff --git a/app/routes/api.users.sign-in.ts b/app/routes/api.users.sign-in.ts index e56c846e0..e33be14c9 100644 --- a/app/routes/api.users.sign-in.ts +++ b/app/routes/api.users.sign-in.ts @@ -1,151 +1,153 @@ +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 { type ZodOpenApiPathItemObject } from 'zod-openapi' +import { + requestContentTypeJson, + responseContentTypeJson, +} from '~/middleware/content-type-header.server' +import { + UnsupportedMediaTypeErrorSchema, + unsupportedMediaTypeResponse, +} from '~/lib/openapi/errors' + +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(), + email: z.email().meta({ + description: "User's email address", + example: 'user@example.com', + }), + 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), + ]), + }), + }, + }, + }, + 415: unsupportedMediaTypeResponse( + UnsupportedMediaTypeErrorSchema, + 'Unsupported content-type. Try application/json.', + ), + 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 95442c3a2..045a51c22 100644 --- a/app/routes/docs.tsx +++ b/app/routes/docs.tsx @@ -1,46 +1,82 @@ +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:

+
- { const results = await Promise.all([ rowCount('device'), - rowCount('sensor'), + rowCount('measurement'), rowCountTimeBucket(measurement, 'time', 60000), ]) diff --git a/package-lock.json b/package-lock.json index 68fc680d8..a95122f59 100644 --- a/package-lock.json +++ b/package-lock.json @@ -67,6 +67,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", @@ -97,7 +98,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", @@ -106,7 +106,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", @@ -131,7 +132,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.6", @@ -161,50 +161,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@apidevtools/json-schema-ref-parser": { - "version": "9.1.2", - "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-9.1.2.tgz", - "integrity": "sha512-r1w81DpR+KyRWd3f+rk6TNqMgedmAxZP5v5KWlXQWlgMUUtyEJch0DKEci1SorPMiSeM8XPl7MZ3miJ60JIpQg==", - "license": "MIT", - "dependencies": { - "@jsdevtools/ono": "^7.1.3", - "@types/json-schema": "^7.0.6", - "call-me-maybe": "^1.0.1", - "js-yaml": "^4.1.0" - } - }, - "node_modules/@apidevtools/openapi-schemas": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@apidevtools/openapi-schemas/-/openapi-schemas-2.1.0.tgz", - "integrity": "sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ==", - "license": "MIT", - "engines": { - "node": ">=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", @@ -3685,12 +3641,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", @@ -10052,13 +10002,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", @@ -10713,6 +10656,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": { @@ -11029,12 +10973,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", @@ -11423,6 +11361,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": { @@ -12141,18 +12080,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", @@ -13307,15 +13234,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", @@ -13755,12 +13673,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", @@ -14500,17 +14412,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", @@ -15658,13 +15559,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", @@ -15677,13 +15571,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", @@ -15714,12 +15601,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", @@ -16801,15 +16682,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", @@ -16838,8 +16710,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", @@ -17070,15 +16942,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", @@ -20348,99 +20211,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", @@ -21388,15 +21158,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", @@ -22173,12 +21934,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", @@ -22357,36 +22112,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", @@ -22414,6 +22139,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 91905fa8e..2ef12edb5 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", @@ -92,6 +91,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", @@ -122,7 +122,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", @@ -131,7 +130,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", @@ -156,7 +156,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.6", diff --git a/public/integration-api.json b/public/integration-api.json new file mode 100644 index 000000000..3987dd73b --- /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 026f6effa..000000000 --- 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') diff --git a/tests/data/generate_test_user.ts b/tests/data/generate_test_user.ts index 6b49b79fe..44ad420b8 100644 --- a/tests/data/generate_test_user.ts +++ b/tests/data/generate_test_user.ts @@ -11,7 +11,7 @@ export const generateTestUserCredentials = (): { } => { return { name: randomUUID().toString(), - email: `${randomBytes(6).toString('hex')}@${randomBytes(6).toString('hex')}.${randomBytes(2).toString('hex')}`, + email: `${randomBytes(6).toString('hex')}@example.com`, password: randomBytes(20).toString('hex'), } } diff --git a/tests/routes/api.boxes.$deviceId.$sensorId.measurement.spec.ts b/tests/routes/api.boxes.$deviceId.$sensorId.measurement.spec.ts index 7e992ef94..abe586da7 100644 --- a/tests/routes/api.boxes.$deviceId.$sensorId.measurement.spec.ts +++ b/tests/routes/api.boxes.$deviceId.$sensorId.measurement.spec.ts @@ -105,8 +105,14 @@ describe('openSenseMap API Routes: /boxes/:deviceId/:sensorId/measurement', () = describe('DELETE', () => { it('should remove measurements by date range (from-date, to-date)', async () => { // Arrange + + const searchParams = new URLSearchParams({ + 'from-date': new Date('1954-01-01 00:00:00+00').toISOString(), + 'to-date': new Date('1954-12-31 23:59:59+00').toISOString(), + }) + const request = new Request( - `${BASE_URL}/api/boxes/${deviceId}/${sensorId}?from-date=${new Date('1954-01-01 00:00:00+00')}&to-date=${new Date('1954-12-31 23:59:59+00')}`, + `${BASE_URL}/api/boxes/${deviceId}/${sensorId}/measurements?${searchParams}`, { method: 'DELETE', headers: { Authorization: `Bearer ${jwt}` } }, ) @@ -129,8 +135,12 @@ describe('openSenseMap API Routes: /boxes/:deviceId/:sensorId/measurement', () = it('should remove measurements by exact timestamps', async () => { // Arrange + const searchParams = new URLSearchParams({ + timestamps: MEASUREMENTS[1].createdAt.toISOString(), + }) + const request = new Request( - `${BASE_URL}/api/boxes/${deviceId}/${sensorId}?timestamps=${MEASUREMENTS[1].createdAt.toISOString()}`, + `${BASE_URL}/api/boxes/${deviceId}/${sensorId}/measurements?${searchParams}`, { method: 'DELETE', headers: { Authorization: `Bearer ${jwt}` } }, ) @@ -200,6 +210,28 @@ describe('openSenseMap API Routes: /boxes/:deviceId/:sensorId/measurement', () = 'You are not allowed to delete data of the given device', ) }) + + it('should not delete all measurements when deleteAllMeasurements=false', async () => { + const request = new Request( + `${BASE_URL}/api/boxes/${deviceId}/${sensorId}/measurements?deleteAllMeasurements=false`, + { method: 'DELETE', headers: { Authorization: `Bearer ${jwt}` } }, + ) + + const dataFunctionValue = await action({ + request, + params: { + deviceId, + sensorId, + } as Params, + } as Route.ActionArgs) + + const response = dataFunctionValue as Response + const body = await response.json() + + expect(response.status).toBe(200) + expect(body).toHaveProperty('message') + expect(body.message).toBe(`Successfully deleted 0 of sensor ${sensorId}`) + }) }) afterAll(async () => { diff --git a/tests/routes/api.boxes.data.spec.ts b/tests/routes/api.boxes.data.spec.ts index 49ca4b19c..d17ab4a0f 100644 --- a/tests/routes/api.boxes.data.spec.ts +++ b/tests/routes/api.boxes.data.spec.ts @@ -131,7 +131,7 @@ describe('openSenseMap API: /boxes/data', () => { expect(res.status).toBe(200) expect(text).not.toBe('') - expect(res.headers.get('content-type')).toBe('text/csv') + expect(res.headers.get('content-type')).toBe('text/csv; charset=utf-8') // Check that CSV has header and data rows const lines = text.trim().split('\n') @@ -149,7 +149,7 @@ describe('openSenseMap API: /boxes/data', () => { expect(res.status).toBe(200) expect(text).not.toBe('') - expect(res.headers.get('content-type')).toBe('text/csv') + expect(res.headers.get('content-type')).toBe('text/csv; charset=utf-8') }) // --------------------------- @@ -164,7 +164,9 @@ describe('openSenseMap API: /boxes/data', () => { const res = await boxesDataLoader({ request: req } as Route.LoaderArgs) expect(res.status).toBe(200) - expect(res.headers.get('content-type')).toBe('application/json') + expect(res.headers.get('content-type')).toBe( + 'application/json; charset=utf-8', + ) const body = await res.json() expect(Array.isArray(body)).toBe(true) @@ -263,7 +265,7 @@ describe('openSenseMap API: /boxes/data', () => { const res = await boxesDataLoader({ request: req } as Route.LoaderArgs) expect(res.status).toBe(200) - expect(res.headers.get('content-type')).toBe('text/csv') + expect(res.headers.get('content-type')).toBe('text/csv; charset=utf-8') const text = (await res.text()).trim() const [header, ...lines] = text.split('\n') @@ -344,7 +346,7 @@ describe('openSenseMap API: /boxes/data', () => { expect(res.status).toBe(200) - expect(res.headers.get('content-type')).toContain('text/csv') + expect(res.headers.get('content-type')).toContain('text/csv; charset=utf-8') const bodyText = await res.text() @@ -401,7 +403,9 @@ describe('openSenseMap API: /boxes/data', () => { const res = await boxesDataLoader({ request: req } as Route.LoaderArgs) expect(res.status).toBe(200) - expect(res.headers.get('content-type')).toBe('application/json') + expect(res.headers.get('content-type')).toBe( + 'application/json; charset=utf-8', + ) const body = await res.json() expect(Array.isArray(body)).toBe(true) diff --git a/tests/routes/api.boxes.spec.ts b/tests/routes/api.boxes.spec.ts index 816048cc4..c41238275 100644 --- a/tests/routes/api.boxes.spec.ts +++ b/tests/routes/api.boxes.spec.ts @@ -75,23 +75,32 @@ describe('openSenseMap API Routes: /boxes', () => { describe('GET', () => { it('should search for boxes with a specific name and limit the results', async () => { - // Arrange - const request = new Request( - `${BASE_URL}?format=geojson&name=${queryableDevice?.name}&limit=2`, - { - method: 'GET', - headers: { 'Content-Type': 'application/json' }, - }, - ) + const searchParams = new URLSearchParams({ + format: 'geojson', + name: queryableDevice?.name ?? '', + limit: '2', + }) - // Act - const response: any = await loader({ - request: request, - } as Route.LoaderArgs) + const request = new Request(`${BASE_URL}?${searchParams}`, { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + }) - expect(response).toBeDefined() - expect(Array.isArray(response?.features)).toBe(true) - expect(response?.features.length).lessThanOrEqual(2) + const response = (await loader({ + request, + } as Route.LoaderArgs)) as Response + + const body = await response.json() + + expect(response.status).toBe(200) + expect(response.headers.get('Content-Type')).toContain( + 'application/geo+json', + ) + + expect(body).toBeDefined() + expect(body.type).toBe('FeatureCollection') + expect(Array.isArray(body.features)).toBe(true) + expect(body.features.length).lessThanOrEqual(2) }) it('should deny searching for a name if limit is greater than max value', async () => { @@ -138,22 +147,31 @@ describe('openSenseMap API Routes: /boxes', () => { }) // Act - const response: any = await loader({ - request: request, - } as Route.LoaderArgs) + const response = (await loader({ + request, + } as Route.LoaderArgs)) as Response + + const body = await response.json() // Assert expect(response).toBeDefined() - expect(response.type).toBe('FeatureCollection') - expect(Array.isArray(response?.features)).toBe(true) + expect(response.status).toBe(200) + expect(response.headers.get('Content-Type')).toContain( + 'application/geo+json', + ) + + expect(body.type).toBe('FeatureCollection') + expect(Array.isArray(body.features)).toBe(true) + + if (body.features.length > 0) { + const feature = body.features[0] - if (response.features.length > 0) { - const feature = response.features[0] expect(feature.type).toBe('Feature') expect(feature.properties).toBeDefined() - // Should have minimal fields const props = feature.properties + + // Should have minimal fields expect(props?._id || props?.id).toBeDefined() expect(props?.name).toBeDefined() @@ -168,64 +186,61 @@ describe('openSenseMap API Routes: /boxes', () => { } }) - it('should return the correct count and correct schema of boxes for /boxes GET with date parameter', async () => { + it('should return the correct schema of boxes for /boxes GET with date parameter', async () => { const tenDaysAgoIso = new Date( Date.now() - 10 * 24 * 60 * 60 * 1000, ).toISOString() + const searchParams = new URLSearchParams({ + format: 'geojson', + date: tenDaysAgoIso, + }) + // Arrange - const request = new Request( - `${BASE_URL}?format=geojson&date=${tenDaysAgoIso}`, - { - method: 'GET', - headers: { 'Content-Type': 'application/json' }, - }, - ) + const request = new Request(`${BASE_URL}?${searchParams}`, { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + }) // Act - const response: any = await loader({ - request: request, - } as Route.LoaderArgs) + const response = (await loader({ + request, + } as Route.LoaderArgs)) as Response + + const geojsonData = await response.json() // Assert expect(response).toBeDefined() - expect(response.type).toBe('FeatureCollection') - expect(Array.isArray(response?.features)).toBe(true) - - // Verify that returned boxes have sensor measurements after the specified date - if (response.features.length > 0) { - response.features.forEach((feature: any) => { - expect(feature.type).toBe('Feature') - expect(feature.properties).toBeDefined() - - // If the box has sensors with measurements, they should be after the date - if ( - feature.properties?.sensors && - Array.isArray(feature.properties.sensors) - ) { - const hasRecentMeasurement = feature.properties.sensors.some( - (sensor: any) => { - if (sensor.lastMeasurement?.createdAt) { - const measurementDate = new Date( - sensor.lastMeasurement.createdAt, - ) - const filterDate = new Date(tenDaysAgoIso) - return measurementDate >= filterDate - } - return false - }, - ) + expect(response.status).toBe(200) + expect(response.headers.get('Content-Type')).toContain( + 'application/geo+json', + ) - // If there are sensors with lastMeasurement, at least one should be recent - if ( - feature.properties.sensors.some( - (s: any) => s.lastMeasurement?.createdAt, - ) - ) { - expect(hasRecentMeasurement).toBe(true) - } + expect(geojsonData.type).toBe('FeatureCollection') + expect(Array.isArray(geojsonData.features)).toBe(true) + + for (const feature of geojsonData.features) { + expect(feature.type).toBe('Feature') + expect(feature.geometry).toBeDefined() + expect(feature.properties).toBeDefined() + + if ( + feature.properties?.sensors && + Array.isArray(feature.properties.sensors) + ) { + const sensorsWithMeasurements = feature.properties.sensors.filter( + (sensor: any) => sensor.lastMeasurement?.createdAt, + ) + + for (const sensor of sensorsWithMeasurements) { + const measurementDate = new Date(sensor.lastMeasurement.createdAt) + const filterDate = new Date(tenDaysAgoIso) + + expect(measurementDate.getTime()).toBeGreaterThanOrEqual( + filterDate.getTime(), + ) } - }) + } } }) @@ -273,25 +288,33 @@ describe('openSenseMap API Routes: /boxes', () => { }) // Act - const geojsonData: any = await loader({ - request: request, - } as Route.LoaderArgs) - - expect(geojsonData).toBeDefined() - if (geojsonData) { - // Assert - this should always be GeoJSON since that's what the loader returns - expect(geojsonData.type).toBe('FeatureCollection') - expect(Array.isArray(geojsonData.features)).toBe(true) - - if (geojsonData.features.length > 0) { - expect(geojsonData.features[0].type).toBe('Feature') - expect(geojsonData.features[0].geometry).toBeDefined() - // @ts-ignore - expect(geojsonData.features[0].geometry.coordinates[0]).toBeDefined() - // @ts-ignore - expect(geojsonData.features[0].geometry.coordinates[1]).toBeDefined() - expect(geojsonData.features[0].properties).toBeDefined() - } + const response = (await loader({ + request, + } as Route.LoaderArgs)) as Response + + const geojsonData = await response.json() + + // Assert + expect(response).toBeDefined() + expect(response.status).toBe(200) + expect(response.headers.get('Content-Type')).toContain( + 'application/geo+json', + ) + + expect(geojsonData.type).toBe('FeatureCollection') + expect(Array.isArray(geojsonData.features)).toBe(true) + + if (geojsonData.features.length > 0) { + const feature = geojsonData.features[0] + + expect(feature.type).toBe('Feature') + expect(feature.geometry).toBeDefined() + expect(feature.geometry.type).toBe('Point') + expect(Array.isArray(feature.geometry.coordinates)).toBe(true) + expect(feature.geometry.coordinates).toHaveLength(2) + expect(feature.geometry.coordinates[0]).toBeDefined() + expect(feature.geometry.coordinates[1]).toBeDefined() + expect(feature.properties).toBeDefined() } }) @@ -333,19 +356,19 @@ describe('openSenseMap API Routes: /boxes', () => { ) // Act - const response: any = await loader({ - request: request, - } as Route.LoaderArgs) + const response = (await loader({ + request, + } as Route.LoaderArgs)) as Response - expect(response).toBeDefined() + const body = await response.json() - if (response) { + if (body) { // Assert - expect(response.type).toBe('FeatureCollection') - expect(Array.isArray(response.features)).toBe(true) + expect(body.type).toBe('FeatureCollection') + expect(Array.isArray(body.features)).toBe(true) - if (response.features.length > 0) { - response.features.forEach((feature: any) => { + if (body.features.length > 0) { + body.features.forEach((feature: any) => { expect(feature.type).toBe('Feature') expect(feature.geometry).toBeDefined() expect(feature.geometry.coordinates).toBeDefined() @@ -397,36 +420,6 @@ describe('openSenseMap API Routes: /boxes', () => { expect(errorData.error).toBe('Invalid format parameter') } }) - - it('should return geojson format when requested', async () => { - // Arrange - const request = new Request(`${BASE_URL}?format=geojson`, { - method: 'GET', - headers: { 'Content-Type': 'application/json' }, - }) - - // Act - const geojsonData: any = await loader({ - request: request, - } as Route.LoaderArgs) - - expect(geojsonData).toBeDefined() - if (geojsonData) { - // Assert - this should always be GeoJSON since that's what the loader returns - expect(geojsonData.type).toBe('FeatureCollection') - expect(Array.isArray(geojsonData.features)).toBe(true) - - if (geojsonData.features.length > 0) { - expect(geojsonData.features[0].type).toBe('Feature') - expect(geojsonData.features[0].geometry).toBeDefined() - // @ts-ignore - expect(geojsonData.features[0].geometry.coordinates[0]).toBeDefined() - // @ts-ignore - expect(geojsonData.features[0].geometry.coordinates[1]).toBeDefined() - expect(geojsonData.features[0].properties).toBeDefined() - } - } - }) }) describe('POST', () => { diff --git a/tests/routes/api.claim.spec.ts b/tests/routes/api.claim.spec.ts index 217e2fcb0..c352a8174 100644 --- a/tests/routes/api.claim.spec.ts +++ b/tests/routes/api.claim.spec.ts @@ -1,13 +1,13 @@ import { generateTestUserCredentials } from 'tests/data/generate_test_user' -import { type Route } from '../../.react-router/types/app/routes/+types/api.claim' -import { type Route as TransferRoute } from '../../.react-router/types/app/routes/+types/api.transfer' +import { type Route } from '../../.react-router/types/app/routes/+types/api.boxes.claim' +import { type Route as TransferRoute } from '../../.react-router/types/app/routes/+types/api.boxes.transfer' import { BASE_URL } from '../../vitest.setup' import { createDevice, getDevice } from '~/db/models/device.server' import { deleteUserByEmail } from '~/db/models/user.server' import { type Device, type User } from '~/db/schema' import { createToken } from '~/lib/jwt' -import { action as claimAction } from '~/routes/api.claim' -import { action as transferAction } from '~/routes/api.transfer' +import { action as claimAction } from '~/routes/api.boxes.claim' +import { action as transferAction } from '~/routes/api.boxes.transfer' import { registerUser } from '~/services/user-service.server' const CLAIM_TEST_USER = generateTestUserCredentials() diff --git a/tests/routes/api.transfers.spec.ts b/tests/routes/api.transfers.spec.ts index caea9ce31..28ed2ebd2 100644 --- a/tests/routes/api.transfers.spec.ts +++ b/tests/routes/api.transfers.spec.ts @@ -1,15 +1,15 @@ -import { type Route } from '../../.react-router/types/app/routes/+types/api.transfer' -import { type Route as TransferDetailRoute } from '../../.react-router/types/app/routes/+types/api.transfer.$deviceId' +import { type Route } from '../../.react-router/types/app/routes/+types/api.boxes.transfer' +import { type Route as TransferDetailRoute } from '../../.react-router/types/app/routes/+types/api.boxes.transfer.$deviceId' import { BASE_URL } from '../../vitest.setup' import { createDevice } from '~/db/models/device.server' import { deleteUserByEmail } from '~/db/models/user.server' import { type Device, type User } from '~/db/schema' import { createToken } from '~/lib/jwt' -import { action as transferAction } from '~/routes/api.transfer' +import { action as transferAction } from '~/routes/api.boxes.transfer' import { action as transferUpdateAction, loader as transferLoader, -} from '~/routes/api.transfer.$deviceId' +} from '~/routes/api.boxes.transfer.$deviceId' import { registerUser } from '~/services/user-service.server' const TRANSFER_TEST_USER = { diff --git a/tests/routes/api.users.me.boxes.$deviceId.spec.ts b/tests/routes/api.users.me.boxes.$deviceId.spec.ts index 93fd847d6..181686e4e 100644 --- a/tests/routes/api.users.me.boxes.$deviceId.spec.ts +++ b/tests/routes/api.users.me.boxes.$deviceId.spec.ts @@ -107,7 +107,7 @@ describe('openSenseMap API Routes: /users', () => { // Assert: Forbidden response expect(forbiddenResponse.status).toBe(403) expect(forbiddenBody.code).toBe('Forbidden') - expect(forbiddenBody.message).toBe('User does not own this senseBox') + expect(forbiddenBody.message).toBe('User does not own this device') }) afterAll(async () => { diff --git a/tests/routes/api.users.refresh-auth.spec.ts b/tests/routes/api.users.refresh-auth.spec.ts index 74479a94a..e0439749b 100644 --- a/tests/routes/api.users.refresh-auth.spec.ts +++ b/tests/routes/api.users.refresh-auth.spec.ts @@ -12,6 +12,7 @@ import { action as meAction, loader as meLoader } from '~/routes/api.users.me' import { action } from '~/routes/api.users.refresh-auth' import { action as signInAction } from '~/routes/api.users.sign-in' import { registerUser } from '~/services/user-service.server' +import { createSignInRequest } from './api.users.sign-in.spec' const VALID_REFRESH_AUTH_TEST_USER = generateTestUserCredentials() const CHANGED_PW_TO = 'some other very secure password' @@ -93,14 +94,10 @@ describe('openSenseMap API Routes: /users', () => { it('should allow to refresh jwt using JSON data', async () => { // Arrange - First sign in to get a fresh refresh token - const signInParams = new URLSearchParams() - signInParams.append('email', VALID_REFRESH_AUTH_TEST_USER.email) - signInParams.append('password', VALID_REFRESH_AUTH_TEST_USER.password) - const signInRequest = new Request(`${BASE_URL}/users/sign-in`, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: signInParams.toString(), - }) + const signInRequest = createSignInRequest( + VALID_REFRESH_AUTH_TEST_USER.email, + VALID_REFRESH_AUTH_TEST_USER.password, + ) const signInResponse = (await signInAction({ request: signInRequest, @@ -212,14 +209,10 @@ describe('openSenseMap API Routes: /users', () => { it('should deny to use the refreshToken after signing out', async () => { // Arrange - const signInParams = new URLSearchParams() - signInParams.append('email', VALID_REFRESH_AUTH_TEST_USER.email) - signInParams.append('password', CHANGED_PW_TO) - const signInRequest = new Request(`${BASE_URL}/users/sign-in`, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: signInParams.toString(), - }) + const signInRequest = createSignInRequest( + VALID_REFRESH_AUTH_TEST_USER.email, + CHANGED_PW_TO, + ) const signOutParams = new URLSearchParams() const signOutRequest = new Request(`${BASE_URL}/users/sign-out`, { diff --git a/tests/routes/api.users.request-password-reset.spec.ts b/tests/routes/api.users.request-password-reset.spec.ts index aca9f032e..39077fc6c 100644 --- a/tests/routes/api.users.request-password-reset.spec.ts +++ b/tests/routes/api.users.request-password-reset.spec.ts @@ -21,14 +21,15 @@ describe('openSenseMap API Routes: /users', () => { describe('POST', () => { it('should allow to request a password reset token', async () => { - const params = new URLSearchParams(VALID_USER) + const body = new URLSearchParams({ + email: VALID_USER.email, + }) const request = new Request( `${BASE_URL}/users/request-password-reset`, { method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: params.toString(), + body, }, ) @@ -36,7 +37,13 @@ describe('openSenseMap API Routes: /users', () => { request, } as Route.ActionArgs)) as Response + const responseBody = await response.json() + expect(response.status).toBe(200) + expect(responseBody).toEqual({ + code: 'Ok', + message: 'Password reset initiated', + }) }) }) diff --git a/tests/routes/api.users.sign-in.spec.ts b/tests/routes/api.users.sign-in.spec.ts index e41f4ceac..5f7408bab 100644 --- a/tests/routes/api.users.sign-in.spec.ts +++ b/tests/routes/api.users.sign-in.spec.ts @@ -10,6 +10,13 @@ const VALID_SIGN_IN_TEST_USER = { password: 'some secure password', } +export const createSignInRequest = (email: string, password: string) => + new Request(`${BASE_URL}/users/sign-in`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + }) + describe('openSenseMap API Routes: /users', () => { describe('/sign-in', () => { beforeAll(async () => { @@ -25,14 +32,10 @@ describe('openSenseMap API Routes: /users', () => { describe('/POST', () => { it('should deny to sign in with wrong password', async () => { // Arrange - const params = new URLSearchParams() - params.append('email', VALID_SIGN_IN_TEST_USER.email) - params.append('password', 'wrong password') - const request = new Request(`${BASE_URL}/users/sign-in`, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: params.toString(), - }) + const request = createSignInRequest( + VALID_SIGN_IN_TEST_USER.email, + 'wrong password', + ) // Act const dataFunctionValue = await action({ @@ -46,24 +49,18 @@ describe('openSenseMap API Routes: /users', () => { }) it('should allow to sign in a user with email and password', async () => { - // Arrange - const params = new URLSearchParams() - params.append('email', VALID_SIGN_IN_TEST_USER.email) - params.append('password', VALID_SIGN_IN_TEST_USER.password) - const request = new Request(`${BASE_URL}/users/sign-in`, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: params.toString(), - }) + const request = createSignInRequest( + VALID_SIGN_IN_TEST_USER.email, + VALID_SIGN_IN_TEST_USER.password, + ) - // Act const dataFunctionValue = await action({ request, } as Route.ActionArgs) + const response = dataFunctionValue as Response - const body = await response?.json() + const body = await response.json() - // Assert expect(dataFunctionValue).toBeInstanceOf(Response) expect(response.status).toBe(200) expect(response.headers.get('content-type')).toBe( @@ -75,14 +72,10 @@ describe('openSenseMap API Routes: /users', () => { it('should allow to sign in a user with name and password', async () => { // Arrange - const params = new URLSearchParams() - params.append('email', VALID_SIGN_IN_TEST_USER.name) - params.append('password', VALID_SIGN_IN_TEST_USER.password) - const request = new Request(`${BASE_URL}/users/sign-in`, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: params.toString(), - }) + const request = createSignInRequest( + VALID_SIGN_IN_TEST_USER.name, + VALID_SIGN_IN_TEST_USER.password, + ) // Act const dataFunctionValue = await action({ @@ -103,14 +96,10 @@ describe('openSenseMap API Routes: /users', () => { it('should allow to sign in a user with email (different case) and password', async () => { // Arrange - const params = new URLSearchParams() - params.append('email', VALID_SIGN_IN_TEST_USER.email.toUpperCase()) - params.append('password', VALID_SIGN_IN_TEST_USER.password) - const request = new Request(`${BASE_URL}/users/sign-in`, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: params.toString(), - }) + const request = createSignInRequest( + VALID_SIGN_IN_TEST_USER.email.toUpperCase(), + VALID_SIGN_IN_TEST_USER.password, + ) // Act const dataFunctionValue = await action({ @@ -131,14 +120,10 @@ describe('openSenseMap API Routes: /users', () => { it('should deny to sign in with name in different case', async () => { // Arrange - const params = new URLSearchParams() - params.append('email', VALID_SIGN_IN_TEST_USER.name.toUpperCase()) - params.append('password', VALID_SIGN_IN_TEST_USER.password) - const request = new Request(`${BASE_URL}/users/sign-in`, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: params.toString(), - }) + const request = createSignInRequest( + VALID_SIGN_IN_TEST_USER.name.toUpperCase(), + VALID_SIGN_IN_TEST_USER.password, + ) // Act const dataFunctionValue = await action({