From 9112e32d2070a73b735c595f6db6555a48dfbc78 Mon Sep 17 00:00:00 2001 From: David Scheidt Date: Thu, 7 May 2026 12:03:41 +0200 Subject: [PATCH 01/29] first working draft with zod schema --- app/middleware/content-type-header.server.ts | 29 ++ app/routes/api.ts | 1 + app/routes/api.users.sign-in.ts | 267 +++++++++---------- app/routes/docs.tsx | 81 +++++- package-lock.json | 112 ++++---- package.json | 4 +- scripts/generate-openapi.ts | 60 ++++- 7 files changed, 350 insertions(+), 204 deletions(-) create mode 100644 app/middleware/content-type-header.server.ts diff --git a/app/middleware/content-type-header.server.ts b/app/middleware/content-type-header.server.ts new file mode 100644 index 00000000..5795f4c8 --- /dev/null +++ b/app/middleware/content-type-header.server.ts @@ -0,0 +1,29 @@ +import { MiddlewareFunction } from 'react-router' +import { StandardResponse } from '~/lib/responses' + +/** + * A middleware function responding with HTTP 415 Unsupported Media Type + * to requests that do not contain the Content-Type: application/json header. + */ +export const requestContentTypeJson: MiddlewareFunction = ({ + request, +}) => { + const contentType = request.headers.get('content-type') || '' + if (!contentType.includes('application/json')) + return StandardResponse.unsupportedMediaType( + 'Unsupported content-type. Try application/json', + ) +} + +/** + * A middleware function that sets the Content-Type: application/json + * header with utf-8 charset for all outgoing responses. + */ +export const responseContentTypeJson: MiddlewareFunction = async ( + _, + next, +) => { + const res = await next() + res.headers.set('Content-Type', 'application/json; charset=utf-8') + return res +} diff --git a/app/routes/api.ts b/app/routes/api.ts index 55bce50a..33a0e4ae 100644 --- a/app/routes/api.ts +++ b/app/routes/api.ts @@ -1,3 +1,4 @@ +/// import { type Route } from '../+types/root' import { apiRoutes as routes } from '~/lib/api-routes' import { tosApiMiddleware } from '~/middleware/tos-api.server' diff --git a/app/routes/api.users.sign-in.ts b/app/routes/api.users.sign-in.ts index e56c846e..c2c1ab2c 100644 --- a/app/routes/api.users.sign-in.ts +++ b/app/routes/api.users.sign-in.ts @@ -1,151 +1,142 @@ +import { z } from 'zod' import { type Route } from './+types/api.users.sign-in' -import { parseUserSignInData } from '~/lib/request-parsing' import { StandardResponse } from '~/lib/responses' import { signIn } from '~/services/user-service.server' -/** - * @openapi - * /api/users/sign-in: - * post: - * tags: - * - Authentication - * summary: User sign-in - * description: Authenticates a user with email and password credentials - * operationId: signInUser - * requestBody: - * required: true - * content: - * application/x-www-form-urlencoded: - * schema: - * type: object - * required: - * - email - * - password - * properties: - * email: - * type: string - * format: email - * description: User's email address - * example: user@example.com - * password: - * type: string - * format: password - * description: User's password - * minLength: 8 - * example: mySecurePassword123 - * responses: - * 200: - * description: Successfully authenticated - * content: - * application/json: - * schema: - * type: object - * properties: - * code: - * type: string - * example: Authorized - * message: - * type: string - * example: Successfully signed in - * data: - * type: object - * properties: - * user: - * $ref: '#/components/schemas/User' - * token: - * type: string - * description: JWT access token - * example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... - * refreshToken: - * type: string - * description: JWT refresh token - * example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... - * 403: - * description: Authentication failed - invalid credentials or missing fields - * content: - * application/json: - * schema: - * type: object - * properties: - * code: - * type: string - * example: Forbidden - * message: - * type: string - * enum: - * - You must specify either your email or your username - * - You must specify your password to sign in - * - User and or password not valid! - * 500: - * description: Internal server error - * content: - * text/plain: - * schema: - * type: string - * example: Internal Server Error - * components: - * schemas: - * User: - * type: object - * description: User information object - * properties: - * id: - * type: string - * description: Unique user identifier - * email: - * type: string - * format: email - * description: User's email address - * name: - * type: string - * description: User's display name - * createdAt: - * type: string - * format: date-time - * description: Account creation timestamp - * updatedAt: - * type: string - * format: date-time - * description: Last account update timestamp - */ -export const action = async ({ request }: Route.ActionArgs) => { - try { - // Parse request data - handles both JSON and form data automatically - const data = await parseUserSignInData(request) +import { ZodOpenApiPathItemObject } from 'zod-openapi' +import { + requestContentTypeJson, + responseContentTypeJson, +} from '~/middleware/content-type-header.server' + +const errorMessages = { + email: 'You must specify either your email or your username', + password: 'You must specify your password to sign in', + userAndOrPassword: 'User and or password not valid!', +} + +const PostRequestSchema = z.object({ + email: z.string(errorMessages.email).trim().nonempty().meta({ + description: "User's email address or username", + example: 'user@example.com', + }), + password: z.string(errorMessages.password).nonempty().min(8).meta({ + description: "User's password", + example: 'mySecurePassword123', + }), +}) - const email = data.email.trim() - const password = data.password.trim() +const PostResponseSchema = z.object({ + data: z.object( + { + user: z.object({ + name: z.string(), + ...PostRequestSchema.pick({ email: true }).shape, + role: z.string(), + language: z.string(), + emailIsConfirmed: z.boolean(), + boxes: z.array(z.string()).meta({ + description: 'A list of ids of the users devices', + example: ['60a13611a877b3001b8ffd59', '5bdbe70f55d0ad001a04edc9'], + }), + }), + }, + errorMessages.userAndOrPassword, + ), + token: z.jwt({ alg: 'HS256', error: errorMessages.userAndOrPassword }).meta({ + description: 'valid json web token', + }), + refreshToken: z.string(errorMessages.userAndOrPassword).meta({ + description: 'valid json web token', + }), + code: z.literal('Authorized').default('Authorized'), + message: z + .literal('Successfully signed in') + .default('Successfully signed in'), +}) - if (!email || email.length === 0) - return StandardResponse.forbidden( - 'You must specify either your email or your username', - ) +export const openapi: ZodOpenApiPathItemObject = { + post: { + tags: ['Auth'], + summary: 'Sign in using email or name and password', + requestBody: { + required: true, + content: { + 'application/json': { schema: PostRequestSchema }, + }, + }, + responses: { + 200: { + description: 'Signed in', + content: { + 'application/json': { schema: PostResponseSchema }, + }, + }, + 403: { + description: 'Unauthorized', + content: { + 'application/json': { + schema: z.object({ + code: z.literal('Forbidden'), + message: z.xor([ + z.literal(errorMessages.email), + z.literal(errorMessages.password), + z.literal(errorMessages.userAndOrPassword), + ]), + error: z.xor([ + z.literal(errorMessages.email), + z.literal(errorMessages.password), + z.literal(errorMessages.userAndOrPassword), + ]), + }), + }, + }, + }, + 500: { + description: 'Internal Server Error', + content: { + 'application/json': { + schema: z.object({ + code: z.literal('Internal Server Error'), + message: z.literal( + 'The server was unable to complete your request. Please try again later.', + ), + error: z.literal( + 'The server was unable to complete your request. Please try again later.', + ), + }), + }, + }, + }, + }, + }, +} - if (!password || password.length === 0) { - return StandardResponse.forbidden( - 'You must specify your password to sign in', - ) - } +export const middleware: Route.MiddlewareFunction[] = [ + requestContentTypeJson, + responseContentTypeJson, +] +export const action = async ({ request }: Route.ActionArgs) => { + try { + const requestParsed = await PostRequestSchema.safeParseAsync( + await request.json(), + ) + if (!requestParsed.success) + return StandardResponse.forbidden(requestParsed.error.issues[0].message) + + const { email, password } = requestParsed.data const { user, jwt, refreshToken } = (await signIn(email, password)) || {} - if (user && jwt && refreshToken) - return StandardResponse.ok({ - code: 'Authorized', - message: 'Successfully signed in', - data: { user }, - token: jwt, - refreshToken, - }) - else return StandardResponse.forbidden('User and or password not valid!') - } catch (error) { - // Handle parsing errors - if (error instanceof Error && error.message.includes('Failed to parse')) { - return StandardResponse.forbidden( - `Invalid request format: ${error.message}`, - ) - } + const responseParsed = await PostResponseSchema.safeParseAsync({ + data: { user }, + token: jwt, + refreshToken, + }) + if (!responseParsed.success) + return StandardResponse.forbidden(responseParsed.error.issues[0].message) - // Handle other errors + return StandardResponse.ok(responseParsed.data) + } catch (error) { console.warn(error) return StandardResponse.internalServerError() } diff --git a/app/routes/docs.tsx b/app/routes/docs.tsx index 95442c3a..0620be4f 100644 --- a/app/routes/docs.tsx +++ b/app/routes/docs.tsx @@ -1,23 +1,82 @@ import { useLoaderData } from 'react-router' import SwaggerUI from 'swagger-ui-react' import 'swagger-ui-react/swagger-ui.css' +import { + createDocument, + ZodOpenApiPathItemObject, + ZodOpenApiPathsObject, +} from 'zod-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 }) + // 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 }) + // } + // const { combinedOpenapiSpecification } = + // await import('~/lib/openapi.combined') + // return Response.json({ + // spec: combinedOpenapiSpecification(), + // }) + + function 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}` } - const { combinedOpenapiSpecification } = - await import('~/lib/openapi.combined') + const routes = import.meta.glob<{ + openapi?: ZodOpenApiPathItemObject + [key: string]: any + }>('/app/routes/api.*.ts', { eager: true }) + + const paths: ZodOpenApiPathsObject = {} - return Response.json({ - spec: combinedOpenapiSpecification(), + for (const [filePath, module] of Object.entries(routes)) { + if (!module.openapi) continue + + const apiPath = convertFilePathToApiPath(filePath) + + // Merge methods into path + paths[apiPath] = { + ...paths[apiPath], + ...module.openapi, + } + } + + const doc = createDocument({ + openapi: '3.1.0', + info: { + title: 'My API', + version: '1.0.0', + }, + paths: paths, }) + + return { spec: doc } } export default function ApiDocumentation() { diff --git a/package-lock.json b/package-lock.json index 16a7ad43..b67cee44 100644 --- a/package-lock.json +++ b/package-lock.json @@ -66,6 +66,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", @@ -105,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", @@ -1172,6 +1174,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1765,6 +1768,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" }, @@ -1813,6 +1817,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" } @@ -1841,27 +1846,6 @@ "integrity": "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==", "license": "Apache-2.0" }, - "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", @@ -1943,6 +1927,7 @@ "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -4269,6 +4254,7 @@ "resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-5.10.5.tgz", "integrity": "sha512-hFQp71QZDfivPzfIUOQZfMKLiOL/Cn2EnzacRlbUr55myteTfzYN8YMt+nzniE/6c4IRopFHEAdbKEtfyQc6kg==", "license": "MIT", + "peer": true, "peerDependencies": { "react": ">=16.8.0" } @@ -5009,6 +4995,7 @@ "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "playwright": "1.59.1" }, @@ -6694,6 +6681,7 @@ "resolved": "https://registry.npmjs.org/@react-router/serve/-/serve-7.15.0.tgz", "integrity": "sha512-0XtYmwc11vWdYn2zeEXx9E3u0I6TH3bm4uDaMdsyI09S6hl6uc98vBkTSXg7Znm3qR82R/jjtn3LvV2QEZ193w==", "license": "MIT", + "peer": true, "dependencies": { "@mjackson/node-fetch-server": "^0.2.0", "@react-router/express": "7.15.0", @@ -6762,6 +6750,7 @@ "resolved": "https://registry.npmjs.org/@rjsf/utils/-/utils-6.5.1.tgz", "integrity": "sha512-c+x2VJNEp0BsamxX+Ryy9sEmwJ/7V9WFsVWjhADwyEU53r7DaVd7a7hmtx0bz464kJ8oJYZ6XghrmXXH2y7l8g==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@x0k/json-schema-merge": "^1.0.3", "fast-uri": "^3.1.0", @@ -8755,18 +8744,6 @@ } } }, - "node_modules/@swagger-api/apidom-parser-adapter-yaml-1-2/node_modules/tree-sitter": { - "version": "0.22.4", - "resolved": "https://registry.npmjs.org/tree-sitter/-/tree-sitter-0.22.4.tgz", - "integrity": "sha512-usbHZP9/oxNsUY65MQUsduGRqDHQOou1cagUSwjhoSYAmSahjQDAVsh9s+SlZkn8X8+O1FULRGwHu7AFP3kjzg==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "dependencies": { - "node-addon-api": "^8.3.0", - "node-gyp-build": "^4.8.4" - } - }, "node_modules/@swagger-api/apidom-reference": { "version": "1.10.2", "resolved": "https://registry.npmjs.org/@swagger-api/apidom-reference/-/apidom-reference-1.10.2.tgz", @@ -9327,6 +9304,7 @@ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -9644,6 +9622,7 @@ "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/node": "*", "pg-protocol": "*", @@ -9671,6 +9650,7 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -9681,6 +9661,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -9823,6 +9804,7 @@ "integrity": "sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.5", @@ -10551,6 +10533,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -10815,6 +10798,7 @@ "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", "license": "MIT", + "peer": true, "dependencies": { "@kurkle/color": "^0.3.0" }, @@ -11585,6 +11569,7 @@ "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/kossnocorp" @@ -13017,6 +13002,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -14042,6 +14028,7 @@ } ], "license": "MIT", + "peer": true, "peerDependencies": { "typescript": "^5 || ^6" }, @@ -14112,6 +14099,7 @@ "resolved": "https://registry.npmjs.org/immutable/-/immutable-3.8.3.tgz", "integrity": "sha512-AUY/VyX0E5XlibOmWt10uabJzam1zlYjwiEgQSDc5+UIkFNaF9WM0JxXKaNMGf+F/ffUF+7kRKXM9A7C0xXqMg==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -14767,6 +14755,7 @@ "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", "devOptional": true, "license": "MIT", + "peer": true, "bin": { "jiti": "lib/jiti-cli.mjs" } @@ -14994,6 +14983,7 @@ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", "license": "MPL-2.0", + "peer": true, "dependencies": { "detect-libc": "^2.0.3" }, @@ -15497,6 +15487,7 @@ "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.24.0.tgz", "integrity": "sha512-ALyFxgtd5R+65UqZ/++lOqwWcC0SNho9c27fYSyLmG7AfnAul2o46F05aDJGPbFU57wos9dgcIySHs0Xe6ia3A==", "license": "BSD-3-Clause", + "peer": true, "dependencies": { "@mapbox/jsonlint-lines-primitives": "^2.0.2", "@mapbox/point-geometry": "^1.1.0", @@ -16486,7 +16477,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" + "license": "MIT", + "peer": true }, "node_modules/outvariant": { "version": "1.4.3", @@ -16519,6 +16511,7 @@ "integrity": "sha512-CopwJOwPAjZ9p76fCvz+mSOJTw9/NY3cSksZK3VO/bUQ8UoEcketNgUuYS0UB3p+R9XnXe7wGGXUmyFxc7QxJA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "tinypool": "2.1.0" }, @@ -16813,6 +16806,7 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", "license": "MIT", + "peer": true, "dependencies": { "pg-connection-string": "^2.12.0", "pg-pool": "^3.13.0", @@ -17082,6 +17076,7 @@ "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.9.tgz", "integrity": "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==", "license": "Unlicense", + "peer": true, "engines": { "node": ">=12" }, @@ -17314,6 +17309,7 @@ "resolved": "https://registry.npmjs.org/ramda/-/ramda-0.30.1.tgz", "integrity": "sha512-tEF5I22zJnuclswcZMc8bDIrwRHRzf+NqVEmqg50ShAZMP7MWeR/RGDthfM/p+BlqvF2fXAzpn8i+SJcYD3alw==", "license": "MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/ramda" @@ -17386,6 +17382,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -17440,6 +17437,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -18038,6 +18036,7 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.73.1.tgz", "integrity": "sha512-VAfVYOPcx3piiEVQy95vyFmBwbVUsP/AUIN+mpFG8h11yshDd444nn0VyfaGWSRnhOLVgiDu7HIuBtAIzxn9dA==", "license": "MIT", + "peer": true, "engines": { "node": ">=18.0.0" }, @@ -18054,6 +18053,7 @@ "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.6.tgz", "integrity": "sha512-WzJ6SMKF+GTD7JZZqxSR1AKKmXjaSu39sClUrNlwxS4Tl7a99O+ltFy6yhPMO+wgZuxpQjJ2PZkfrQKmAqrLhw==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.29.2", "html-parse-stringify": "^3.0.1", @@ -18241,6 +18241,7 @@ "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.15.0.tgz", "integrity": "sha512-HW9vYwuM8f4yx66Izy8xfrzCM+SBJluoZcCbww9A1TySax11S5Vgw6fi3ZjMONw9J4gQwngL7PzkyIpJJpJ7RQ==", "license": "MIT", + "peer": true, "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" @@ -18408,7 +18409,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/redux-immutable": { "version": "4.0.0", @@ -18670,6 +18672,7 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz", "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==", "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -20006,7 +20009,8 @@ "version": "4.2.4", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.4.tgz", "integrity": "sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tailwindcss-animate": { "version": "1.0.7", @@ -20035,7 +20039,8 @@ "version": "0.184.0", "resolved": "https://registry.npmjs.org/three/-/three-0.184.0.tgz", "integrity": "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/three-conic-polygon-geometry": { "version": "2.1.2", @@ -20306,18 +20311,6 @@ "node": ">=20" } }, - "node_modules/tree-sitter": { - "version": "0.21.1", - "resolved": "https://registry.npmjs.org/tree-sitter/-/tree-sitter-0.21.1.tgz", - "integrity": "sha512-7dxoA6kYvtgWw80265MyqJlkRl4yawIjO7S5MigytjELkX43fV2WsAXzsNfO7sBpPPCF5Gp0+XzHk0DwLCq3xQ==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "dependencies": { - "node-addon-api": "^8.0.0", - "node-gyp-build": "^4.8.0" - } - }, "node_modules/tree-sitter-json": { "version": "0.24.8", "resolved": "https://registry.npmjs.org/tree-sitter-json/-/tree-sitter-json-0.24.8.tgz", @@ -20507,6 +20500,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -20878,6 +20872,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==", "license": "MIT", + "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", @@ -21233,6 +21228,7 @@ "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.1.5", "@vitest/mocker": "4.1.5", @@ -21835,6 +21831,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -21850,6 +21847,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 972b6251..81c1ce84 100644 --- a/package.json +++ b/package.json @@ -91,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", @@ -130,7 +131,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", diff --git a/scripts/generate-openapi.ts b/scripts/generate-openapi.ts index 026f6eff..f800dae5 100644 --- a/scripts/generate-openapi.ts +++ b/scripts/generate-openapi.ts @@ -1,9 +1,61 @@ import { writeFileSync } from 'node:fs' import { combinedOpenapiSpecification } from '../app/lib/openapi.combined.js' +import { createDocument } from 'zod-openapi' -writeFileSync( - './public/openapi.json', - JSON.stringify(combinedOpenapiSpecification(), null, 2), -) +function 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}` +} + +const routes = import.meta.glob<{ + openapi?: object + [key: string]: any +}>('/app/routes/api.*.ts', { eager: true }) + +const paths: Record> = {} + +for (const [filePath, module] of Object.entries(routes)) { + if (!module.openapi) continue + + const apiPath = convertFilePathToApiPath(filePath) + + // Merge methods into path + paths[apiPath] = { + ...paths[apiPath], + ...module.openapi, + } +} + +const doc = createDocument({ + openapi: '3.1.0', + info: { + title: 'My API', + version: '1.0.0', + }, + paths: {}, +}) + +writeFileSync('./public/openapi.json', JSON.stringify(doc, null, 2)) console.info('โœ… OpenAPI spec generated') From 6dc641c409b670e1aa0a024cfedee10272c3347a Mon Sep 17 00:00:00 2001 From: David Scheidt Date: Thu, 7 May 2026 14:54:18 +0200 Subject: [PATCH 02/29] draft full workflow --- .github/workflows/deploy.yml | 3 - .gitignore | 2 - app/lib/env.server.ts | 1 + app/lib/integration.openapi.ts | 460 ---------------------------- app/lib/openapi.combined.ts | 172 ----------- app/lib/openapi.ts | 539 ++++++++++++++++++++++++++++----- app/routes/docs.tsx | 133 ++++---- package-lock.json | 285 +---------------- package.json | 3 - public/integration-api.json | 8 + scripts/generate-openapi.ts | 61 ---- 11 files changed, 533 insertions(+), 1134 deletions(-) delete mode 100644 app/lib/integration.openapi.ts delete mode 100644 app/lib/openapi.combined.ts create mode 100644 public/integration-api.json delete mode 100644 scripts/generate-openapi.ts diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index ba8fdece..7f19ca4a 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -53,9 +53,6 @@ jobs: - name: ๐Ÿ“ฅ Install deps run: npm install - - name: ๐Ÿงพ Generate OpenAPI spec - run: npm run build:docs - - name: ๐Ÿ”Ž Type check run: npm run typecheck --if-present diff --git a/.gitignore b/.gitignore index b80cdd22..2d38df51 100644 --- a/.gitignore +++ b/.gitignore @@ -6,7 +6,6 @@ node_modules /build /public/build -/public/openapi.json .env .cache @@ -26,4 +25,3 @@ measurements.csv /minio-data /rustfs-data -/public/openapi.json diff --git a/app/lib/env.server.ts b/app/lib/env.server.ts index 6d80ad9f..6999b77b 100644 --- a/app/lib/env.server.ts +++ b/app/lib/env.server.ts @@ -39,6 +39,7 @@ export function init() { export function getEnv() { return { NOMINATIM_SEARCH_API: process.env.NOMINATIM_SEARCH_API, + OSEM_GITHUB_URL: process.env.OSEM_API_URL, MODE: process.env.NODE_ENV, DIRECTUS_URL: process.env.DIRECTUS_URL, MYBADGES_API_URL: process.env.MYBADGES_API_URL, diff --git a/app/lib/integration.openapi.ts b/app/lib/integration.openapi.ts deleted file mode 100644 index e58140bc..00000000 --- a/app/lib/integration.openapi.ts +++ /dev/null @@ -1,460 +0,0 @@ -import swaggerJsdoc from 'swagger-jsdoc' - -const options: swaggerJsdoc.Options = { - definition: { - openapi: '3.0.0', - info: { - title: 'OpenSenseMap Integration API', - version: '1.0.0', - description: ` -# Building OpenSenseMap Integrations - -OpenSenseMap uses a plugin architecture for integrations. Any service implementing this specification can connect devices from any platform or protocol to OpenSenseMap. - -## Architecture - -\`\`\` -OpenSenseMap (Main App) - โ†“ Registers & calls via HTTP -Your Integration Service - โ†“ Receives data from -Your Protocol (MQTT/LoRa/etc.) -\`\`\` - -## Required Endpoints - -Your integration service MUST implement these endpoints: - -1. **GET /integrations/:deviceId** - Get integration config -2. **PUT /integrations/:deviceId** - Create/update integration config -3. **DELETE /integrations/:deviceId** - Delete integration config -4. **GET /integrations/schema/{name}** - Return JSON Schema for config form -5. **GET /health** - Health check - -## Authentication - -All endpoints (except /health) require \`x-service-key\` header. - -## Forwarding Measurements - -After processing data, POST measurements to OpenSenseMap: - -**Endpoint:** \`POST /api/boxes/:deviceId/:sensorId\` - -**Headers:** -- \`Content-Type: application/json\` -- \`x-service-key\`: Your service key (provided by OpenSenseMap) - -**Body:** -\`\`\`json -{ - "value": 23.5, - "createdAt": "2026-02-06T10:00:00Z", - "location": { - "lng": 7.628, - "lat": 51.963, - "height": 100 - } -} -\`\`\` - -## Reference Implementations - -- [MQTT Integration](https://github.com/opensensemap/mqtt-integration) -- [TTN Integration](https://github.com/opensensemap/ttn-integration) - -## Registration - -To register your integration, contact OpenSenseMap admins with: -- Service name and description -- Service URL and authentication key -- Icon (Lucide icon name) -- JSON Schema endpoint path - `, - }, - servers: [ - { - url: 'https://your-integration-service.com', - description: 'Your integration microservice', - }, - ], - components: { - securitySchemes: { - ServiceKey: { - type: 'apiKey', - in: 'header', - name: 'x-service-key', - description: 'Service authentication key configured in OpenSenseMap', - }, - }, - parameters: { - DeviceId: { - name: 'deviceId', - in: 'path', - required: true, - schema: { - type: 'string', - }, - description: 'OpenSenseMap device ID', - example: 'cm65qexample123', - }, - }, - schemas: { - IntegrationConfig: { - type: 'object', - description: - 'Integration configuration (schema varies by integration type)', - additionalProperties: true, - example: { - id: 'intg_123', - deviceId: 'cm65qexample123', - enabled: true, - url: 'mqtt://broker.example.com', - topic: 'sensors/data', - messageFormat: 'json', - }, - }, - JsonSchema: { - type: 'object', - description: 'JSON Schema (draft-07) for dynamic form generation', - properties: { - schema: { - type: 'object', - description: 'JSON Schema definition', - }, - uiSchema: { - type: 'object', - description: 'React JSON Schema Form UI Schema', - }, - }, - required: ['schema'], - }, - Error: { - type: 'object', - properties: { - error: { - type: 'string', - }, - details: { - type: 'array', - items: { - type: 'string', - }, - }, - }, - }, - }, - responses: { - NotFound: { - description: 'Resource not found', - content: { - 'application/json': { - schema: { - $ref: '#/components/schemas/Error', - }, - example: { - error: 'Integration not found', - }, - }, - }, - }, - ValidationError: { - description: 'Validation failed', - content: { - 'application/json': { - schema: { - $ref: '#/components/schemas/Error', - }, - example: { - error: 'Validation failed', - details: [ - 'url is required and must be a string', - 'topic is required and must be a string', - ], - }, - }, - }, - }, - Unauthorized: { - description: 'Unauthorized - invalid or missing service key', - content: { - 'application/json': { - schema: { - $ref: '#/components/schemas/Error', - }, - example: { - error: 'Unauthorized', - }, - }, - }, - }, - InternalError: { - description: 'Internal server error', - content: { - 'application/json': { - schema: { - $ref: '#/components/schemas/Error', - }, - example: { - error: 'Internal server error', - }, - }, - }, - }, - }, - }, - security: [ - { - ServiceKey: [], - }, - ], - tags: [ - { - name: 'Integration Management', - description: 'CRUD operations for integration configurations', - }, - { - name: 'Schema', - description: 'JSON Schema for dynamic form generation', - }, - { - name: 'Health', - description: 'Service health check', - }, - ], - paths: { - '/integrations/{deviceId}': { - get: { - summary: 'Get integration configuration for a device', - operationId: 'getIntegration', - tags: ['Integration Management'], - parameters: [ - { - $ref: '#/components/parameters/DeviceId', - }, - ], - responses: { - '200': { - description: 'Integration configuration', - content: { - 'application/json': { - schema: { - $ref: '#/components/schemas/IntegrationConfig', - }, - }, - }, - }, - '404': { - $ref: '#/components/responses/NotFound', - }, - '401': { - $ref: '#/components/responses/Unauthorized', - }, - '500': { - $ref: '#/components/responses/InternalError', - }, - }, - }, - put: { - summary: 'Create or update integration configuration', - operationId: 'createOrUpdateIntegration', - tags: ['Integration Management'], - parameters: [ - { - $ref: '#/components/parameters/DeviceId', - }, - ], - requestBody: { - required: true, - content: { - 'application/json': { - schema: { - type: 'object', - description: - 'Configuration specific to your integration type', - additionalProperties: true, - }, - examples: { - mqtt: { - summary: 'MQTT Integration', - value: { - url: 'mqtt://broker.example.com:1883', - topic: 'sensors/temperature', - messageFormat: 'json', - connectionOptions: { - username: 'user', - password: 'pass', - }, - }, - }, - ttn: { - summary: 'TTN Integration', - value: { - devId: 'my-device', - appId: 'my-app', - profile: 'cayenne-lpp', - }, - }, - }, - }, - }, - }, - responses: { - '200': { - description: 'Integration updated', - content: { - 'application/json': { - schema: { - $ref: '#/components/schemas/IntegrationConfig', - }, - }, - }, - }, - '201': { - description: 'Integration created', - content: { - 'application/json': { - schema: { - $ref: '#/components/schemas/IntegrationConfig', - }, - }, - }, - }, - '400': { - $ref: '#/components/responses/ValidationError', - }, - '401': { - $ref: '#/components/responses/Unauthorized', - }, - '500': { - $ref: '#/components/responses/InternalError', - }, - }, - }, - delete: { - summary: 'Delete integration configuration', - operationId: 'deleteIntegration', - tags: ['Integration Management'], - parameters: [ - { - $ref: '#/components/parameters/DeviceId', - }, - ], - responses: { - '204': { - description: 'Integration deleted successfully', - }, - '404': { - $ref: '#/components/responses/NotFound', - }, - '401': { - $ref: '#/components/responses/Unauthorized', - }, - '500': { - $ref: '#/components/responses/InternalError', - }, - }, - }, - }, - '/integrations/schema/{integrationName}': { - get: { - summary: 'Get JSON Schema for integration configuration form', - operationId: 'getIntegrationSchema', - tags: ['Schema'], - parameters: [ - { - name: 'integrationName', - in: 'path', - required: true, - schema: { - type: 'string', - }, - example: 'mqtt', - }, - ], - responses: { - '200': { - description: 'JSON Schema for dynamic form generation', - content: { - 'application/json': { - schema: { - $ref: '#/components/schemas/JsonSchema', - }, - examples: { - mqtt: { - summary: 'MQTT Schema Example', - value: { - schema: { - type: 'object', - required: ['url', 'topic', 'messageFormat'], - properties: { - url: { - type: 'string', - title: 'Broker URL', - pattern: '^(mqtt|mqtts|ws|wss)://.+', - }, - topic: { - type: 'string', - title: 'Topic', - }, - messageFormat: { - type: 'string', - title: 'Message Format', - enum: ['json', 'csv'], - }, - }, - }, - uiSchema: { - 'ui:order': ['url', 'topic', 'messageFormat'], - }, - }, - }, - }, - }, - }, - }, - '401': { - $ref: '#/components/responses/Unauthorized', - }, - '500': { - $ref: '#/components/responses/InternalError', - }, - }, - }, - }, - '/health': { - get: { - summary: 'Health check endpoint', - operationId: 'healthCheck', - tags: ['Health'], - security: [], - responses: { - '200': { - description: 'Service is healthy', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - status: { - type: 'string', - example: 'healthy', - }, - timestamp: { - type: 'string', - format: 'date-time', - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - apis: [], -} - -export const integrationOpenapiSpecification = () => swaggerJsdoc(options) diff --git a/app/lib/openapi.combined.ts b/app/lib/openapi.combined.ts deleted file mode 100644 index ba3da16f..00000000 --- a/app/lib/openapi.combined.ts +++ /dev/null @@ -1,172 +0,0 @@ -import { type OpenAPIV3 } from 'openapi-types' -import { integrationOpenapiSpecification } from './integration.openapi' -import { openapiSpecification } from './openapi' - -type OpenAPIDocumentWithTagGroups = OpenAPIV3.Document & { - 'x-tagGroups'?: Array<{ name: string; tags: string[] }> -} - -const tagAnchor = (tag: string) => `#/${encodeURIComponent(tag)}` - -function collectOperationTagNames(paths?: OpenAPIV3.PathsObject): string[] { - if (!paths) return [] - - const set = new Set() - - for (const pathItem of Object.values(paths)) { - const item = (pathItem ?? {}) as OpenAPIV3.PathItemObject - - const methods = [ - 'get', - 'put', - 'post', - 'delete', - 'patch', - 'options', - 'trace', - ] as const - - for (const m of methods) { - const op = item[m] - if (!op?.tags) continue - for (const t of op.tags) set.add(t) - } - } - - return [...set] -} - -function prefixOperationTags( - paths: OpenAPIV3.PathsObject | undefined, - prefix: string, -): OpenAPIV3.PathsObject { - const result: OpenAPIV3.PathsObject = {} - if (!paths) return result - - for (const [path, pathItem] of Object.entries(paths)) { - const item = (pathItem ?? {}) as OpenAPIV3.PathItemObject - const out: OpenAPIV3.PathItemObject = { ...item } - - const methods = [ - 'get', - 'put', - 'post', - 'delete', - 'patch', - 'options', - ] as const - - for (const m of methods) { - const op = item[m] - if (!op) continue - - out[m] = { - ...op, - tags: op.tags?.map((t) => `${prefix} ยท ${t}`), - } - } - - result[path] = out - } - - return result -} - -function mergeComponents( - a?: OpenAPIV3.ComponentsObject, - b?: OpenAPIV3.ComponentsObject, -): OpenAPIV3.ComponentsObject | undefined { - if (!a && !b) return undefined - return { - ...(a ?? {}), - ...(b ?? {}), - schemas: { ...(a?.schemas ?? {}), ...(b?.schemas ?? {}) }, - parameters: { ...(a?.parameters ?? {}), ...(b?.parameters ?? {}) }, - responses: { ...(a?.responses ?? {}), ...(b?.responses ?? {}) }, - securitySchemes: { - ...(a?.securitySchemes ?? {}), - ...(b?.securitySchemes ?? {}), - }, - } -} - -export const combinedOpenapiSpecification = - (): OpenAPIDocumentWithTagGroups => { - const main = openapiSpecification() as OpenAPIV3.Document - const integration = integrationOpenapiSpecification() as OpenAPIV3.Document - - const mainTagNames = - (main.tags?.map((t) => t.name) ?? []).length > 0 - ? main.tags!.map((t) => t.name) - : collectOperationTagNames(main.paths) - - const integrationTagNames = - (integration.tags?.map((t) => t.name) ?? []).length > 0 - ? integration.tags!.map((t) => t.name) - : collectOperationTagNames(integration.paths) - - const publicTags: OpenAPIV3.TagObject[] = - (main.tags?.length ?? 0) > 0 - ? main.tags!.map((t) => ({ ...t, name: `Public ยท ${t.name}` })) - : mainTagNames.sort().map((name) => ({ name: `Public ยท ${name}` })) - - const integrationTags: OpenAPIV3.TagObject[] = - (integration.tags?.length ?? 0) > 0 - ? integration.tags!.map((t) => ({ - ...t, - name: `Integration ยท ${t.name}`, - })) - : integrationTagNames - .sort() - .map((name) => ({ name: `Integration ยท ${name}` })) - - const allTags = [...publicTags, ...integrationTags] - - const publicJump = publicTags[0]?.name - const integrationJump = integrationTags[0]?.name - - const topLinks = [ - publicJump ? `- [Public API](${tagAnchor(publicJump)})` : `- Public API`, - integrationJump - ? `- [Integration API](${tagAnchor(integrationJump)})` - : `- Integration API`, - ].join('\n') - - return { - ...main, - - info: { - ...main.info, - title: 'OpenSenseMap API', - description: `# OpenSenseMap API Documentation - -Use the links below or the sidebar to navigate. - -${topLinks} -`, - }, - - // Public first => Integration appears below in Swagger UI (assuming no tagsSorter="alpha") - tags: allTags, - - paths: { - ...prefixOperationTags(main.paths, 'Public'), - ...prefixOperationTags(integration.paths, 'Integration'), - }, - - components: mergeComponents(main.components, integration.components), - - security: [...(main.security ?? []), ...(integration.security ?? [])], - - 'x-tagGroups': [ - { - name: 'Public API', - tags: publicTags.map((t) => t.name), - }, - { - name: 'Integration API', - tags: integrationTags.map((t) => t.name), - }, - ], - } - } diff --git a/app/lib/openapi.ts b/app/lib/openapi.ts index 0ce373b1..cb96b782 100644 --- a/app/lib/openapi.ts +++ b/app/lib/openapi.ts @@ -1,124 +1,521 @@ -import swaggerJsdoc from 'swagger-jsdoc' +import { + ZodOpenApiObject, + ZodOpenApiPathItemObject, + ZodOpenApiPathsObject, +} from 'zod-openapi' const DEV_SERVER = { url: 'http://localhost:3000', description: 'Development server', } -const options: swaggerJsdoc.Options = { - definition: { - openapi: '3.0.0', +const convertFilePathToApiPath = (filePath: string) => { + // Extract filename and remove extension + // /app/routes/api.users.$id.tsx -> api.users.$id + const fileName = + filePath + .split('/') + .pop() + ?.replace(/\.(tsx|ts|jsx|js)$/, '') || '' + + // Handle root routes + if (fileName === 'root' || fileName === 'home' || fileName === 'index') { + return '/' + } + + // Convert dots to slashes (path separator convention) + // api.users.$id -> api/users/$id + let path = fileName.replace(/\./g, '/') + + // Convert $param to {param} for OpenAPI + // api/users/$id -> api/users/{id} + path = path.replace(/\$(\w+)/g, '{$1}') + + // Add leading slash + return `/${path}` +} + +export const generateOpenApiPathsSpec = (): ZodOpenApiPathsObject => { + const routes = import.meta.glob<{ + openapi?: ZodOpenApiPathItemObject + [key: string]: any + }>('/app/routes/api.*.ts', { eager: true }) + + const paths: ZodOpenApiPathsObject = {} + + for (const [filePath, module] of Object.entries(routes)) { + if (!module.openapi) continue + + const apiPath = convertFilePathToApiPath(filePath) + + // Merge methods into path + paths[apiPath] = { + ...paths[apiPath], + ...module.openapi, + } + } + + return paths +} + +export const generateOpenApiServerSpec = (): { + url: string + description: string +}[] => { + return [ + ...(process.env.NODE_ENV !== 'production' ? [DEV_SERVER] : []), + { + url: process.env.OSEM_API_URL, + description: 'Production server', + }, + ] +} + +export const generateIntegrationApiSpec = (): ZodOpenApiObject => { + return { + openapi: '3.1.0', info: { - title: 'openSenseMap API', + title: 'API Schema for Integrations', version: '1.0.0', - description: `## Documentation of the routes and methods to manage users, stations (also called boxes or senseBoxes), and measurements in the openSenseMap API. You can find the API running at [https://opensensemap.org/api/](https://opensensemap.org/api/). -# Timestamps + description: ` +# Building OpenSenseMap Integrations -## Please note that the API handles every timestamp in Coordinated universal time (UTC) time zone. Timestamps in parameters should be in RFC 3339 notation. +OpenSenseMap uses a plugin architecture for integrations. Any service implementing this specification can connect devices from any platform or protocol to OpenSenseMap. -**Timestamp without Milliseconds:** +## Architecture \`\`\` -2018-02-01T23:18:02Z +OpenSenseMap (Main App) + โ†“ Registers & calls via HTTP +Your Integration Service + โ†“ Receives data from +Your Protocol (MQTT/LoRa/etc.) \`\`\` -**Timestamp with Milliseconds:** +## Required Endpoints -\`\`\` -2018-02-01T23:18:02.412Z -\`\`\` +Your integration service MUST implement these endpoints: -# IDs +1. **GET /integrations/:deviceId** - Get integration config +2. **PUT /integrations/:deviceId** - Create/update integration config +3. **DELETE /integrations/:deviceId** - Delete integration config +4. **GET /integrations/schema/{name}** - Return JSON Schema for config form +5. **GET /health** - Health check -## All stations and sensors of stations receive a unique public identifier. These identifiers are exactly 24 character long and only contain digits and characters a to f. +## Authentication -**Example:** +All endpoints (except /health) require \`x-service-key\` header. -\`\`\` -5a8d1c25bc2d41001927a265 -\`\`\` +## Forwarding Measurements -# Parameters +After processing data, POST measurements to OpenSenseMap: -## Only if noted otherwise, all requests assume the payload encoded as JSON with \`Content-type: application/json\` header. Parameters prepended with a colon (\`:\`) are parameters which should be specified through the URL. +**Endpoint:** \`POST /api/boxes/:deviceId/:sensorId\` -# Source code and Licenses +**Headers:** +- \`Content-Type: application/json\` +- \`x-service-key\`: Your service key (provided by OpenSenseMap) -## You can find the whole source code of the API at GitHub in the [sensebox/openSenseMap-API](https://github.com/sensebox/openSenseMap-API) repository. You can obtain the code under the MIT License. +**Body:** +\`\`\`json +{ + "value": 23.5, + "createdAt": "2026-02-06T10:00:00Z", + "location": { + "lng": 7.628, + "lat": 51.963, + "height": 100 + } +} +\`\`\` -## The data obtainable through the openSenseMap API at [https://opensensemap.org/api/](https://opensensemap.org/api/) is licensed under the [Public Domain Dedication and License 1.0](https://opendatacommons.org/licenses/pddl/summary/). +## Reference Implementations -## If there is something unclear or there is a mistake in this documentation please open an [issue](https://github.com/openSenseMap/frontend/issues/new) in the GitHub repository.`, +- [MQTT Integration](https://github.com/opensensemap/mqtt-integration) +- [TTN Integration](https://github.com/opensensemap/ttn-integration) + +## Registration + +To register your integration, contact OpenSenseMap admins with: +- Service name and description +- Service URL and authentication key +- Icon (Lucide icon name) +- JSON Schema endpoint path + `, }, servers: [ - ...(process.env.NODE_ENV !== 'production' ? [DEV_SERVER] : []), { - url: process.env.OSEM_API_URL || 'https://opensensemap.org/api', // Uses environment variable or defaults to production URL - description: 'Production server', + url: 'https://your-integration-service.com', + description: 'Your integration microservice', }, ], components: { - schemas: { - SenseBoxId: { - type: 'string', - pattern: '^[a-f0-9]{24}$', - description: - 'Unique identifier for stations and sensors (24 characters, digits and a-f only)', - example: '5a8d1c25bc2d41001927a265', - }, - Timestamp: { - type: 'string', - format: 'date-time', - description: 'RFC 3339 timestamp in UTC timezone', - examples: ['2018-02-01T23:18:02Z', '2018-02-01T23:18:02.412Z'], + securitySchemes: { + ServiceKey: { + type: 'apiKey', + in: 'header', + name: 'x-service-key', + description: 'Service authentication key configured in OpenSenseMap', }, }, parameters: { - SenseBoxIdParam: { - name: 'id', + DeviceId: { + name: 'deviceId', in: 'path', required: true, schema: { - $ref: '#/components/schemas/SenseBoxId', + type: 'string', }, - description: 'SenseBox ID parameter', + description: 'OpenSenseMap device ID', + example: 'cm65qexample123', }, - TimestampParam: { - name: 'timestamp', - in: 'query', - schema: { - $ref: '#/components/schemas/Timestamp', + }, + schemas: { + IntegrationConfig: { + type: 'object', + description: + 'Integration configuration (schema varies by integration type)', + additionalProperties: true, + example: { + id: 'intg_123', + deviceId: 'cm65qexample123', + enabled: true, + url: 'mqtt://broker.example.com', + topic: 'sensors/data', + messageFormat: 'json', + }, + }, + JsonSchema: { + type: 'object', + description: 'JSON Schema (draft-07) for dynamic form generation', + properties: { + schema: { + type: 'object', + description: 'JSON Schema definition', + }, + uiSchema: { + type: 'object', + description: 'React JSON Schema Form UI Schema', + }, + }, + required: ['schema'], + }, + Error: { + type: 'object', + properties: { + error: { + type: 'string', + }, + details: { + type: 'array', + items: { + type: 'string', + }, + }, }, - description: 'Timestamp parameter in RFC 3339 format (UTC)', }, }, responses: { - BadRequest: { - description: 'Bad Request - Invalid parameters or payload', + NotFound: { + description: 'Resource not found', content: { 'application/json': { schema: { - type: 'object', - properties: { - error: { - type: 'string', - example: 'Invalid request parameters', - }, - }, + $ref: '#/components/schemas/Error', + }, + example: { + error: 'Integration not found', }, }, }, }, - NotFound: { - description: 'Resource not found', + ValidationError: { + description: 'Validation failed', content: { 'application/json': { schema: { - type: 'object', - properties: { - error: { - type: 'string', - example: 'Resource not found', + $ref: '#/components/schemas/Error', + }, + example: { + error: 'Validation failed', + details: [ + 'url is required and must be a string', + 'topic is required and must be a string', + ], + }, + }, + }, + }, + Unauthorized: { + description: 'Unauthorized - invalid or missing service key', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Error', + }, + example: { + error: 'Unauthorized', + }, + }, + }, + }, + InternalError: { + description: 'Internal server error', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Error', + }, + example: { + error: 'Internal server error', + }, + }, + }, + }, + }, + }, + security: [ + { + ServiceKey: [], + }, + ], + tags: [ + { + name: 'Integration Management', + description: 'CRUD operations for integration configurations', + }, + { + name: 'Schema', + description: 'JSON Schema for dynamic form generation', + }, + { + name: 'Health', + description: 'Service health check', + }, + ], + paths: { + '/integrations/{deviceId}': { + get: { + summary: 'Get integration configuration for a device', + operationId: 'getIntegration', + tags: ['Integration Management'], + parameters: [ + { + $ref: '#/components/parameters/DeviceId', + }, + ], + responses: { + '200': { + description: 'Integration configuration', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/IntegrationConfig', + }, + }, + }, + }, + '404': { + $ref: '#/components/responses/NotFound', + }, + '401': { + $ref: '#/components/responses/Unauthorized', + }, + '500': { + $ref: '#/components/responses/InternalError', + }, + }, + }, + put: { + summary: 'Create or update integration configuration', + operationId: 'createOrUpdateIntegration', + tags: ['Integration Management'], + parameters: [ + { + $ref: '#/components/parameters/DeviceId', + }, + ], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + description: + 'Configuration specific to your integration type', + additionalProperties: true, + }, + examples: { + mqtt: { + summary: 'MQTT Integration', + value: { + url: 'mqtt://broker.example.com:1883', + topic: 'sensors/temperature', + messageFormat: 'json', + connectionOptions: { + username: 'user', + password: 'pass', + }, + }, + }, + ttn: { + summary: 'TTN Integration', + value: { + devId: 'my-device', + appId: 'my-app', + profile: 'cayenne-lpp', + }, + }, + }, + }, + }, + }, + responses: { + '200': { + description: 'Integration updated', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/IntegrationConfig', + }, + }, + }, + }, + '201': { + description: 'Integration created', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/IntegrationConfig', + }, + }, + }, + }, + '400': { + $ref: '#/components/responses/ValidationError', + }, + '401': { + $ref: '#/components/responses/Unauthorized', + }, + '500': { + $ref: '#/components/responses/InternalError', + }, + }, + }, + delete: { + summary: 'Delete integration configuration', + operationId: 'deleteIntegration', + tags: ['Integration Management'], + parameters: [ + { + $ref: '#/components/parameters/DeviceId', + }, + ], + responses: { + '204': { + description: 'Integration deleted successfully', + }, + '404': { + $ref: '#/components/responses/NotFound', + }, + '401': { + $ref: '#/components/responses/Unauthorized', + }, + '500': { + $ref: '#/components/responses/InternalError', + }, + }, + }, + }, + '/integrations/schema/{integrationName}': { + get: { + summary: 'Get JSON Schema for integration configuration form', + operationId: 'getIntegrationSchema', + tags: ['Schema'], + parameters: [ + { + name: 'integrationName', + in: 'path', + required: true, + schema: { + type: 'string', + }, + example: 'mqtt', + }, + ], + responses: { + '200': { + description: 'JSON Schema for dynamic form generation', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/JsonSchema', + }, + examples: { + mqtt: { + summary: 'MQTT Schema Example', + value: { + schema: { + type: 'object', + required: ['url', 'topic', 'messageFormat'], + properties: { + url: { + type: 'string', + title: 'Broker URL', + pattern: '^(mqtt|mqtts|ws|wss)://.+', + }, + topic: { + type: 'string', + title: 'Topic', + }, + messageFormat: { + type: 'string', + title: 'Message Format', + enum: ['json', 'csv'], + }, + }, + }, + uiSchema: { + 'ui:order': ['url', 'topic', 'messageFormat'], + }, + }, + }, + }, + }, + }, + }, + '401': { + $ref: '#/components/responses/Unauthorized', + }, + '500': { + $ref: '#/components/responses/InternalError', + }, + }, + }, + }, + '/health': { + get: { + summary: 'Health check endpoint', + operationId: 'healthCheck', + tags: ['Health'], + security: [], + responses: { + '200': { + description: 'Service is healthy', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + status: { + type: 'string', + example: 'healthy', + }, + timestamp: { + type: 'string', + format: 'date-time', + }, + }, }, }, }, @@ -127,9 +524,5 @@ const options: swaggerJsdoc.Options = { }, }, }, - }, - // Path to the API routes containing JSDoc annotations - apis: ['./app/routes/api.*.ts'], // Adjust path as needed + } } - -export const openapiSpecification = () => swaggerJsdoc(options) diff --git a/app/routes/docs.tsx b/app/routes/docs.tsx index 0620be4f..b808a15f 100644 --- a/app/routes/docs.tsx +++ b/app/routes/docs.tsx @@ -1,105 +1,80 @@ +import { useCallback, useState } from 'react' import { useLoaderData } from 'react-router' import SwaggerUI from 'swagger-ui-react' import 'swagger-ui-react/swagger-ui.css' +import { createDocument } from 'zod-openapi' import { - createDocument, - ZodOpenApiPathItemObject, - ZodOpenApiPathsObject, -} from 'zod-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 }) - // } - // const { combinedOpenapiSpecification } = - // await import('~/lib/openapi.combined') - // return Response.json({ - // spec: combinedOpenapiSpecification(), - // }) - - function 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}` - } - - const routes = import.meta.glob<{ - openapi?: ZodOpenApiPathItemObject - [key: string]: any - }>('/app/routes/api.*.ts', { eager: true }) - - const paths: ZodOpenApiPathsObject = {} - - for (const [filePath, module] of Object.entries(routes)) { - if (!module.openapi) continue - - const apiPath = convertFilePathToApiPath(filePath) - - // Merge methods into path - paths[apiPath] = { - ...paths[apiPath], - ...module.openapi, - } - } + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '~/components/ui/select' +import { + generateIntegrationApiSpec, + generateOpenApiPathsSpec, + generateOpenApiServerSpec, +} from '~/lib/openapi' +export const loader = () => { const doc = createDocument({ openapi: '3.1.0', info: { - title: 'My API', + 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/', + }, }, - paths: paths, + servers: generateOpenApiServerSpec(), + paths: generateOpenApiPathsSpec(), }) - return { spec: doc } + const integration = createDocument({ ...generateIntegrationApiSpec() }) + + 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
- - {/* Optional manual TOC */} -
- - Public API - - - Integration API - +
+

Choose API:

+
- =10" - } - }, - "node_modules/@apidevtools/swagger-methods": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@apidevtools/swagger-methods/-/swagger-methods-3.0.2.tgz", - "integrity": "sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==", - "license": "MIT" - }, - "node_modules/@apidevtools/swagger-parser": { - "version": "10.0.3", - "resolved": "https://registry.npmjs.org/@apidevtools/swagger-parser/-/swagger-parser-10.0.3.tgz", - "integrity": "sha512-sNiLY51vZOmSPFZA5TF35KZ2HbgYklQnTSDnkghamzLb3EkNtcQnrBQEj5AOCxHpTtXpqMCRM1CrmV2rG6nw4g==", - "license": "MIT", - "dependencies": { - "@apidevtools/json-schema-ref-parser": "^9.0.6", - "@apidevtools/openapi-schemas": "^2.0.4", - "@apidevtools/swagger-methods": "^3.0.2", - "@jsdevtools/ono": "^7.1.3", - "call-me-maybe": "^1.0.1", - "z-schema": "^5.0.1" - }, - "peerDependencies": { - "openapi-types": ">=7" - } - }, "node_modules/@asamuzakjp/css-color": { "version": "5.1.11", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", @@ -3623,12 +3577,6 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@jsdevtools/ono": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", - "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", - "license": "MIT" - }, "node_modules/@kurkle/color": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", @@ -9691,13 +9639,6 @@ "@types/geojson": "*" } }, - "node_modules/@types/swagger-jsdoc": { - "version": "6.0.4", - "resolved": "https://registry.npmjs.org/@types/swagger-jsdoc/-/swagger-jsdoc-6.0.4.tgz", - "integrity": "sha512-W+Xw5epcOZrF/AooUM/PccNMSAFOKWZA5dasNyMujTwsBkU74njSJBpvCCJhHAJ95XRMzQrrW844Btu0uoetwQ==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/swagger-ui-react": { "version": "5.18.0", "resolved": "https://registry.npmjs.org/@types/swagger-ui-react/-/swagger-ui-react-5.18.0.tgz", @@ -10352,6 +10293,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, "license": "MIT" }, "node_modules/base64-js": { @@ -10668,12 +10610,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/call-me-maybe": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz", - "integrity": "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==", - "license": "MIT" - }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -11062,6 +10998,7 @@ "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, "license": "MIT" }, "node_modules/conf": { @@ -11780,18 +11717,6 @@ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "license": "MIT" }, - "node_modules/doctrine": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", - "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", - "license": "Apache-2.0", - "dependencies": { - "esutils": "^2.0.2" - }, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/dom-accessibility-api": { "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", @@ -12946,15 +12871,6 @@ "@types/estree": "^1.0.0" } }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -13393,12 +13309,6 @@ "js-yaml": "bin/js-yaml.js" } }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "license": "ISC" - }, "node_modules/fsevents": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", @@ -14138,17 +14048,6 @@ "node": ">=12" } }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "license": "ISC", - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -15296,13 +15195,6 @@ "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", "license": "MIT" }, - "node_modules/lodash.get": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", - "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", - "deprecated": "This package is deprecated. Use the optional chaining (?.) operator instead.", - "license": "MIT" - }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -15315,13 +15207,6 @@ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", "license": "MIT" }, - "node_modules/lodash.isequal": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", - "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", - "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", - "license": "MIT" - }, "node_modules/lodash.isinteger": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", @@ -15352,12 +15237,6 @@ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "license": "MIT" }, - "node_modules/lodash.mergewith": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz", - "integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==", - "license": "MIT" - }, "node_modules/lodash.once": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", @@ -16439,15 +16318,6 @@ "node": ">= 0.8" } }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, "node_modules/openapi-path-templating": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/openapi-path-templating/-/openapi-path-templating-2.2.1.tgz", @@ -16476,8 +16346,8 @@ "version": "12.1.3", "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==", - "license": "MIT", - "peer": true + "dev": true, + "license": "MIT" }, "node_modules/outvariant": { "version": "1.4.3", @@ -16708,15 +16578,6 @@ "node": ">=14.0.0" } }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -19795,99 +19656,6 @@ "ramda-adjunct": "^5.1.0" } }, - "node_modules/swagger-jsdoc": { - "version": "6.2.8", - "resolved": "https://registry.npmjs.org/swagger-jsdoc/-/swagger-jsdoc-6.2.8.tgz", - "integrity": "sha512-VPvil1+JRpmJ55CgAtn8DIcpBs0bL5L3q5bVQvF4tAW/k/9JYSj7dCpaYCAv5rufe0vcCbBRQXGvzpkWjvLklQ==", - "license": "MIT", - "dependencies": { - "commander": "6.2.0", - "doctrine": "3.0.0", - "glob": "7.1.6", - "lodash.mergewith": "^4.6.2", - "swagger-parser": "^10.0.3", - "yaml": "2.0.0-1" - }, - "bin": { - "swagger-jsdoc": "bin/swagger-jsdoc.js" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/swagger-jsdoc/node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/swagger-jsdoc/node_modules/commander": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.0.tgz", - "integrity": "sha512-zP4jEKbe8SHzKJYQmq8Y9gYjtO/POJLgIdKgV7B9qNmABVFVc+ctqSX6iXh4mCpJfRBOabiZ2YKPg8ciDw6C+Q==", - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/swagger-jsdoc/node_modules/glob": { - "version": "7.1.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", - "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.0.4", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/swagger-jsdoc/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/swagger-jsdoc/node_modules/yaml": { - "version": "2.0.0-1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.0.0-1.tgz", - "integrity": "sha512-W7h5dEhywMKenDJh2iX/LABkbFnBxasD27oyXWDS/feDsxiw0dD5ncXdYXgkvAsXIY2MpW/ZKkr9IU30DBdMNQ==", - "license": "ISC", - "engines": { - "node": ">= 6" - } - }, - "node_modules/swagger-parser": { - "version": "10.0.3", - "resolved": "https://registry.npmjs.org/swagger-parser/-/swagger-parser-10.0.3.tgz", - "integrity": "sha512-nF7oMeL4KypldrQhac8RyHerJeGPD1p2xDh900GPvc+Nk7nWP6jX2FcC7WmkinMoAmoO774+AFXcWsW8gMWEIg==", - "license": "MIT", - "dependencies": { - "@apidevtools/swagger-parser": "10.0.3" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/swagger-ui-react": { "version": "5.32.4", "resolved": "https://registry.npmjs.org/swagger-ui-react/-/swagger-ui-react-5.32.4.tgz", @@ -20835,15 +20603,6 @@ "spdx-expression-parse": "^3.0.0" } }, - "node_modules/validator": { - "version": "13.15.35", - "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.35.tgz", - "integrity": "sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==", - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -21620,12 +21379,6 @@ "node": ">=8" } }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "license": "ISC" - }, "node_modules/ws": { "version": "8.18.3", "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", @@ -21789,36 +21542,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/z-schema": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/z-schema/-/z-schema-5.0.5.tgz", - "integrity": "sha512-D7eujBWkLa3p2sIpJA0d1pr7es+a7m0vFAnZLlCEKq/Ij2k0MLi9Br2UPxoxdYystm5K1yeBGzub0FlYUEWj2Q==", - "license": "MIT", - "dependencies": { - "lodash.get": "^4.4.2", - "lodash.isequal": "^4.5.0", - "validator": "^13.7.0" - }, - "bin": { - "z-schema": "bin/z-schema" - }, - "engines": { - "node": ">=8.0.0" - }, - "optionalDependencies": { - "commander": "^9.4.1" - } - }, - "node_modules/z-schema/node_modules/commander": { - "version": "9.5.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", - "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", - "license": "MIT", - "optional": true, - "engines": { - "node": "^12.20.0 || >=14" - } - }, "node_modules/zenscroll": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/zenscroll/-/zenscroll-4.0.2.tgz", diff --git a/package.json b/package.json index e497052a..5b5d9292 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,6 @@ "build": "run-s build:*", "build:drizzle": "npm run db:generate", "build:react-router": "react-router build", - "build:docs": "tsx ./scripts/generate-openapi.ts", "start": "cross-env NODE_ENV=production react-router-serve ./build/server/index.js", "test": "vitest", "test:ui": "vitest --ui", @@ -121,7 +120,6 @@ "remix-i18next": "^7.5.0", "rollup-preserve-directives": "^1.1.3", "simple-statistics": "^7.8.9", - "swagger-jsdoc": "^6.2.8", "swagger-ui-react": "^5.32.4", "tailwind-merge": "^3.5.0", "tailwindcss-animate": "^1.0.7", @@ -156,7 +154,6 @@ "@types/react": "^19.2.14", "@types/react-dom": "19.2.3", "@types/supercluster": "^7.1.3", - "@types/swagger-jsdoc": "^6.0.4", "@types/swagger-ui-react": "^5.18.0", "@vitejs/plugin-react": "^6.0.1", "@vitest/coverage-v8": "^4.1.5", diff --git a/public/integration-api.json b/public/integration-api.json new file mode 100644 index 00000000..3987dd73 --- /dev/null +++ b/public/integration-api.json @@ -0,0 +1,8 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "My API", + "version": "1.0.0" + }, + "paths": {} +} \ No newline at end of file diff --git a/scripts/generate-openapi.ts b/scripts/generate-openapi.ts deleted file mode 100644 index f800dae5..00000000 --- a/scripts/generate-openapi.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { writeFileSync } from 'node:fs' -import { combinedOpenapiSpecification } from '../app/lib/openapi.combined.js' -import { createDocument } from 'zod-openapi' - -function 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}` -} - -const routes = import.meta.glob<{ - openapi?: object - [key: string]: any -}>('/app/routes/api.*.ts', { eager: true }) - -const paths: Record> = {} - -for (const [filePath, module] of Object.entries(routes)) { - if (!module.openapi) continue - - const apiPath = convertFilePathToApiPath(filePath) - - // Merge methods into path - paths[apiPath] = { - ...paths[apiPath], - ...module.openapi, - } -} - -const doc = createDocument({ - openapi: '3.1.0', - info: { - title: 'My API', - version: '1.0.0', - }, - paths: {}, -}) - -writeFileSync('./public/openapi.json', JSON.stringify(doc, null, 2)) - -console.info('โœ… OpenAPI spec generated') From 53a34118962de3363b662904cb02ada6175d48b1 Mon Sep 17 00:00:00 2001 From: jona159 Date: Fri, 15 May 2026 17:08:22 +0200 Subject: [PATCH 03/29] fix: rm unused apis --- app/routes/api.getsensors.ts | 98 ------------------------------- app/routes/api.measurements.ts | 104 --------------------------------- 2 files changed, 202 deletions(-) delete mode 100644 app/routes/api.getsensors.ts delete mode 100644 app/routes/api.measurements.ts diff --git a/app/routes/api.getsensors.ts b/app/routes/api.getsensors.ts deleted file mode 100644 index 35ed4ce4..00000000 --- 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 7afecde7..00000000 --- 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}`) - } -} From 3f2ba106ebc64f07431a1f1d735fc41a33d86875 Mon Sep 17 00:00:00 2001 From: jona159 Date: Fri, 15 May 2026 17:08:37 +0200 Subject: [PATCH 04/29] feat: update readme section about openapi docs --- README.md | 208 +++++++++++++++++++++++++++++++++++------------------- 1 file changed, 134 insertions(+), 74 deletions(-) diff --git a/README.md b/README.md index 29dd93dd..66ec5733 100644 --- a/README.md +++ b/README.md @@ -124,91 +124,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 From 50d3109fa29e028cfc5389b43390f9f895c4c097 Mon Sep 17 00:00:00 2001 From: jona159 Date: Fri, 15 May 2026 17:09:00 +0200 Subject: [PATCH 05/29] feat: add auth token to security schemes --- app/lib/openapi.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/lib/openapi.ts b/app/lib/openapi.ts index cb96b782..e66f5944 100644 --- a/app/lib/openapi.ts +++ b/app/lib/openapi.ts @@ -151,6 +151,12 @@ To register your integration, contact OpenSenseMap admins with: ], components: { securitySchemes: { + bearerAuth: { + type: 'http', + scheme: 'bearer', + bearerFormat: 'JWT', + description: 'JWT access token obtained from the sign-in endpoint', + }, ServiceKey: { type: 'apiKey', in: 'header', From 8d217d298f41aa2afe031b53c325f50bd657f0b6 Mon Sep 17 00:00:00 2001 From: jona159 Date: Fri, 15 May 2026 17:09:19 +0200 Subject: [PATCH 06/29] feat(wip): update openapi docs --- .../api.boxes.$deviceId.data.$sensorId.ts | 406 ++++++---- app/routes/api.boxes.$deviceId.locations.ts | 348 +++++--- app/routes/api.boxes.$deviceId.sensors.ts | 287 ++++++- app/routes/api.boxes.$deviceId.ts | 558 +++++++++++-- app/routes/api.boxes.ts | 743 ++++++++++-------- app/routes/api.users.me.ts | 703 ++++++++++------- app/routes/api.users.refresh-auth.ts | 274 ++++--- app/routes/api.users.sign-in.ts | 250 +++--- 8 files changed, 2359 insertions(+), 1210 deletions(-) diff --git a/app/routes/api.boxes.$deviceId.data.$sensorId.ts b/app/routes/api.boxes.$deviceId.data.$sensorId.ts index 1a3cb915..9192ab2a 100644 --- a/app/routes/api.boxes.$deviceId.data.$sensorId.ts +++ b/app/routes/api.boxes.$deviceId.data.$sensorId.ts @@ -9,138 +9,224 @@ 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' -/** - * @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 - */ +const messages = { + invalidDeviceId: 'Invalid device id specified', + invalidSensorId: 'Invalid sensor id specified', + deviceNotFound: 'Device not found.', + internal: + 'The server was unable to complete your request. Please try again later.', +} + +const standardErrorResponseSchema = ( + code: Code, + messageSchema: z.ZodType = z.string(), +) => + z.object({ + code: z.literal(code), + message: messageSchema, + error: messageSchema, + }) + +const SensorDataPathParamsSchema = z.object({ + deviceId: z.string().min(1).meta({ + description: + 'The ID of the device you are referring to. This parameter is kept for legacy route compatibility.', + example: '5bdbe70f55d0ad001a04edc9', + }), + sensorId: z.string().min(1).meta({ + description: 'The ID of the sensor you are referring to', + example: '6649b23072c4c40007105953', + }), +}) + +const SensorDataQueryParamsSchema = z.object({ + 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 moving window used as base to calculate the outliers.', + example: 15, + }), + 'from-date': z.string().datetime().optional().meta({ + description: + 'Beginning date of measurement data. Defaults to 48 hours ago from now.', + example: '2026-05-13T12:00:00.000Z', + }), + 'to-date': z.string().datetime().optional().meta({ + description: 'End date of measurement data. Defaults to now.', + example: '2026-05-15T12:00:00.000Z', + }), + format: z.enum(['json', 'csv']).default('json').meta({ + description: "Response format. Can be 'json' or 'csv'. Defaults to 'json'.", + example: 'json', + }), + download: z.enum(['true', 'false']).optional().meta({ + description: + 'If set to `true`, the API sets a `Content-Disposition` header so browsers download the response instead of displaying it.', + example: 'true', + }), + delimiter: z.enum(['comma', 'semicolon']).default('comma').meta({ + description: + 'Only for CSV responses. Controls the CSV delimiter. Possible values are `comma` and `semicolon`. Defaults to `comma`. Do not use together with `separator`.', + example: 'comma', + }), + separator: z.enum(['comma', 'semicolon']).optional().meta({ + description: + 'Alias for `delimiter`. Only for CSV responses. Do not use together with `delimiter`.', + example: 'semicolon', + }), +}) + +const SensorMeasurementSchema = z + .object({ + sensorId: z.string().meta({ + description: 'ID of the sensor this measurement belongs to', + example: '6649b23072c4c40007105953', + }), + time: z.string().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', + }), + 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` query parameter.', + example: + 'createdAt,value\n2023-09-29T08:06:13.254Z,6.38\n2023-09-29T08:06:12.312Z,6.38\n2023-09-29T08:06:11.513Z,6.38', +}) + +const BadRequestErrorSchema = standardErrorResponseSchema( + 'Bad Request', + z.string().meta({ + examples: [ + messages.invalidDeviceId, + messages.invalidSensorId, + 'Illegal value for parameter outlier-window. Allowed values: numbers between 1 and 50', + ], + }), +).meta({ id: 'BadRequestError' }) + +const NotFoundErrorSchema = standardErrorResponseSchema( + 'Not Found', + z.literal(messages.deviceNotFound), +).meta({ id: 'NotFoundError' }) + +const InternalServerErrorSchema = standardErrorResponseSchema( + 'Internal Server Error', + z.literal(messages.internal), +).meta({ id: 'InternalServerError' }) + +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: SensorDataPathParamsSchema, + query: SensorDataQueryParamsSchema, + }, + + responses: { + 200: { + description: 'Success', + 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: { + description: + 'Bad request. This can happen for invalid path parameters, invalid dates, invalid enum parameters, or an invalid outlier window.', + content: { + 'application/json': { + schema: BadRequestErrorSchema, + }, + }, + }, + 404: { + description: 'Device or sensor not found', + content: { + 'application/json': { + schema: NotFoundErrorSchema, + }, + }, + }, + 500: { + description: 'Internal server error', + content: { + 'application/json': { + schema: InternalServerErrorSchema, + }, + }, + }, + }, + }, +} export const loader = async ({ request, @@ -176,10 +262,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 +282,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 +319,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 +336,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 +366,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.locations.ts b/app/routes/api.boxes.$deviceId.locations.ts index 1d87e367..5892ea56 100644 --- a/app/routes/api.boxes.$deviceId.locations.ts +++ b/app/routes/api.boxes.$deviceId.locations.ts @@ -1,96 +1,209 @@ +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 - */ +const messages = { + invalidDeviceId: 'Invalid device id specified', + deviceNotFound: 'Device not found', + internal: + 'The server was unable to complete your request. Please try again later.', +} + +const standardErrorResponseSchema = ( + code: Code, + messageSchema: z.ZodType = z.string(), +) => + z.object({ + code: z.literal(code), + message: messageSchema, + error: messageSchema, + }) + +const DeviceLocationsPathParamsSchema = z.object({ + deviceId: z.string().min(1).meta({ + description: 'The ID of the device you are referring to', + example: '60a13611a877b3001b8ffd59', + }), +}) + +const DeviceLocationsQueryParamsSchema = z.object({ + 'from-date': z.string().datetime().optional().meta({ + description: + 'Beginning date of location data. Defaults to 48 hours ago from now.', + example: '2026-05-13T12:00:00.000Z', + }), + 'to-date': z.string().datetime().optional().meta({ + description: 'End date of location data. Defaults to now.', + example: '2026-05-15T12:00:00.000Z', + }), + format: z.enum(['json', 'geojson']).default('json').meta({ + description: "Can be 'json' or 'geojson'. Defaults to 'json'.", + example: 'json', + }), +}) + +const CoordinatesSchema = z.tuple([z.number(), z.number()]).meta({ + description: '[longitude, latitude]', + example: [7.68123, 51.9123], +}) + +const PointLocationSchema = z + .object({ + coordinates: CoordinatesSchema, + type: z.literal('Point'), + timestamp: z.string().datetime().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( + z.string().datetime().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 BadRequestErrorSchema = standardErrorResponseSchema( + 'Bad Request', + z.string().meta({ + example: messages.invalidDeviceId, + }), +).meta({ id: 'BadRequestError' }) + +const NotFoundErrorSchema = standardErrorResponseSchema( + 'Not Found', + z.literal(messages.deviceNotFound), +).meta({ id: 'NotFoundError' }) + +const InternalServerErrorSchema = standardErrorResponseSchema( + 'Internal Server Error', + z.string().meta({ + example: messages.internal, + }), +).meta({ id: 'InternalServerError' }) + +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: DeviceLocationsPathParamsSchema, + query: DeviceLocationsQueryParamsSchema, + }, + + responses: { + 200: { + description: 'Success', + content: { + 'application/json': { + schema: JsonLocationsResponseSchema, + }, + 'application/geo+json': { + schema: GeoJsonLineStringResponseSchema, + }, + }, + }, + 400: { + description: + 'Bad request. This can happen for an invalid device id, invalid date parameter, or invalid format parameter.', + content: { + 'application/json': { + schema: BadRequestErrorSchema, + }, + }, + }, + 404: { + description: 'Device not found', + content: { + 'application/json': { + schema: NotFoundErrorSchema, + }, + }, + }, + 500: { + description: 'Internal server error', + content: { + 'application/json': { + schema: InternalServerErrorSchema, + }, + }, + }, + }, + }, +} export const loader = async ({ request, @@ -99,10 +212,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(messages.deviceNotFound) + } const jsonLocations = locations.map((location) => { return { @@ -112,32 +229,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 +270,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(messages.invalidDeviceId) + } const url = new URL(request.url) @@ -166,18 +285,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.sensors.ts b/app/routes/api.boxes.$deviceId.sensors.ts index 5ac9ed13..efe24066 100644 --- a/app/routes/api.boxes.$deviceId.sensors.ts +++ b/app/routes/api.boxes.$deviceId.sensors.ts @@ -1,34 +1,245 @@ 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' -/** - * @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 = { + invalidDeviceId: 'Invalid device id specified', + invalidCount: + 'Illegal value for parameter count. allowed values: numbers from 1 to 100', + deviceNotFound: 'Device not found', + internal: + 'The server was unable to complete your request. Please try again later.', +} + +const standardErrorResponseSchema = ( + code: Code, + messageSchema: z.ZodType = z.string(), +) => + z.object({ + code: z.literal(code), + message: messageSchema, + error: messageSchema, + }) + +const unknownJsonSchema = z.unknown().nullable().meta({ + description: 'Arbitrary JSON data', +}) + +const LastMeasurementSchema = z + .object({ + value: z.number().nullable().meta({ + example: 23.42, + }), + createdAt: z.string().datetime().meta({ + example: '2026-05-15T12:00:00.000Z', + }), + sensorId: z.string().meta({ + example: '60a13611a877b3001b8ffd59', + }), + }) + .meta({ + id: 'LastMeasurement', + description: 'Cached latest measurement for a sensor.', + }) + +const DeviceSensorsPathParamsSchema = z.object({ + deviceId: z.string().min(1).meta({ + description: 'The ID of the device you are referring to', + example: '60a13611a877b3001b8ffd59', + }), +}) + +const DeviceSensorsQueryParamsSchema = z.object({ + count: z.coerce.number().int().min(1).max(100).optional().meta({ + description: 'Number of measurements to retrieve for every sensor', + example: 5, + }), +}) + +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.string().datetime().meta({ + description: 'Sensor creation timestamp', + example: '2026-05-15T12:00:00.000Z', + }), + updatedAt: z.string().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.', + }) + +const MeasurementSchema = z + .object({ + sensorId: z.string().meta({ + description: 'ID of the sensor this measurement belongs to', + example: '60a13611a877b3001b8ffd59', + }), + time: z.string().datetime().meta({ + description: 'Measurement timestamp', + example: '2026-05-15T12:00:00.000Z', + }), + value: z.number().nullable().meta({ + description: 'Measured value', + example: 23.42, + }), + /** + * Note: if this field is returned as a real JS bigint, + * Response.json will fail because BigInt cannot be JSON serialized. + * If it is converted before returning, document it as string or number. + */ + locationId: z.union([z.string(), z.number()]).nullable().meta({ + description: 'Location id associated with the measurement', + example: '123', + }), + }) + .meta({ + id: 'Measurement', + description: 'Measurement data.', + }) + +const SensorWithLatestMeasurementSchema = SensorSchema.and( + MeasurementSchema, +).meta({ + id: 'SensorWithLatestMeasurement', + description: 'Sensor metadata combined with its latest measurement fields.', +}) + +const DeviceWithSensorsSchema = z + .looseObject({ + 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. Additional device fields are included according to the device model.', + }) + +const BadRequestErrorSchema = standardErrorResponseSchema( + 'Bad Request', + z.union([ + z.literal(messages.invalidDeviceId), + z.literal(messages.invalidCount), + ]), +).meta({ id: 'BadRequestError' }) + +const NotFoundErrorSchema = standardErrorResponseSchema( + 'Not Found', + z.literal(messages.deviceNotFound), +).meta({ id: 'NotFoundError' }) + +const InternalServerErrorSchema = standardErrorResponseSchema( + 'Internal Server Error', + z.string().meta({ + example: messages.internal, + }), +).meta({ id: 'InternalServerError' }) + +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 its latest measurement data. The optional `count` query parameter controls how many measurements are retrieved per sensor, depending on service behavior.', + operationId: 'getDeviceSensorMeasurements', + + requestParams: { + path: DeviceSensorsPathParamsSchema, + query: DeviceSensorsQueryParamsSchema, + }, + + responses: { + 200: { + description: 'Success', + content: { + 'application/json': { + schema: DeviceWithSensorsSchema, + }, + }, + }, + 400: { + description: + 'Bad request. This can happen for an invalid device id or invalid count parameter.', + content: { + 'application/json': { + schema: BadRequestErrorSchema, + }, + }, + }, + 404: { + description: 'Device not found', + content: { + 'application/json': { + schema: NotFoundErrorSchema, + }, + }, + }, + 500: { + description: 'Internal server error', + content: { + 'application/json': { + schema: InternalServerErrorSchema, + }, + }, + }, + }, + }, +} export const loader = async ({ request, @@ -36,22 +247,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(messages.invalidDeviceId) + } 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(messages.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 5a643e49..15579162 100644 --- a/app/routes/api.boxes.$deviceId.ts +++ b/app/routes/api.boxes.$deviceId.ts @@ -10,67 +10,505 @@ import { transformDeviceToApiFormat } from '~/lib/device-transform' import { getUserFromJwt } from '~/lib/jwt' import { StandardResponse } from '~/lib/responses' import { deleteDevice } from '~/services/devices-service.server' +import { z } from 'zod' +import { type ZodOpenApiPathItemObject } from 'zod-openapi' + +const messages = { + deviceIdRequired: 'Device ID is required.', + deviceNotFound: 'Device not found.', + invalidJwt: 'Invalid JWT authorization. Please sign in to obtain a new JWT.', + passwordRequired: 'Password is required for device deletion', + passwordIncorrect: 'Password incorrect', + conflictingSensorsAndAddons: + 'sensors and addons can not appear in the same request.', + internalFetching: 'Internal server error while fetching box', + internalDefault: + 'The server was unable to complete your request. Please try again later.', +} + +const standardErrorResponseSchema = ( + code: Code, + messageSchema: z.ZodType = z.string(), +) => + z.object({ + code: z.literal(code), + message: messageSchema, + error: messageSchema, + }) + +const DevicePathParamsSchema = z.object({ + deviceId: z.string().min(1).meta({ + description: 'Unique identifier of the device', + example: '5bdbe70f55d0ad001a04edc9', + }), +}) + +const LocationInputSchema = 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.', + }) + +const SensorUpdateSchema = z + .looseObject({ + 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: 'SensorUpdate', + description: 'Sensor update or creation payload.', + }) + +const DeviceAddonsSchema = 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.', + }) + +const UpdateDeviceRequestSchema = z + .object({ + name: z.string().optional().meta({ + description: 'Device name', + example: 'My senseBox', + }), + exposure: z.string().optional().meta({ + description: 'Device exposure', + example: 'outdoor', + }), + description: z.string().optional().meta({ + description: 'Device description', + example: 'Sensor box on my balcony', + }), + image: z.string().optional().meta({ + description: 'Device image URL or image value', + example: 'https://example.com/image.jpg', + }), + deleteImage: z.boolean().optional().meta({ + description: + 'If true, the device image is removed by setting `image` to an empty string.', + 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. This is mapped to `link` internally.', + example: 'https://example.com', + }), + location: LocationInputSchema.optional(), + grouptag: z + .array(z.string()) + .optional() + .meta({ + description: 'Group tags assigned to the device', + example: ['school', 'feinstaub'], + }), + sensors: z.array(SensorUpdateSchema).optional().meta({ + description: + 'Sensors to update or create. Must not be used together with `addons.add`.', + }), + addons: DeviceAddonsSchema.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, messages.passwordRequired).meta({ + description: 'Current user password required to delete the device', + example: 'myCurrentPassword123', + format: 'password', + }), + }) + .meta({ + id: 'DeleteDeviceRequest', + description: 'Device deletion confirmation payload.', + }) + +const DeviceSchema = z + .looseObject({ + id: z.string().meta({ + description: 'Device id', + example: '5bdbe70f55d0ad001a04edc9', + }), + name: z.string().optional().meta({ + description: 'Device name', + example: 'My senseBox', + }), + exposure: z.string().optional().meta({ + description: 'Device exposure', + example: 'outdoor', + }), + description: z.string().nullable().optional().meta({ + description: 'Device description', + example: 'Sensor box on my balcony', + }), + model: z.string().nullable().optional().meta({ + description: 'Device model', + example: 'homeWifi', + }), + useAuth: z.boolean().optional().meta({ + description: 'Whether device API-key authentication is enabled', + example: true, + }), + sensors: z.array(z.looseObject({})).optional().meta({ + description: 'Sensors belonging to this device', + }), + createdAt: z.string().datetime().optional().meta({ + description: 'Device creation timestamp', + example: '2026-05-15T12:00:00.000Z', + }), + updatedAt: z.string().datetime().optional().meta({ + description: 'Device update timestamp', + example: '2026-05-15T12:00:00.000Z', + }), + }) + .meta({ + id: 'Device', + description: + 'Device object. Additional fields may be included depending on the database model.', + }) + +const ApiDeviceSchema = DeviceSchema.meta({ + id: 'ApiDevice', + description: + 'Device object transformed to API format. Additional fields may be included depending on `transformDeviceToApiFormat`.', +}) + +const BadRequestErrorSchema = z + .union([ + standardErrorResponseSchema( + 'Bad Request', + z.union([ + z.literal(messages.deviceIdRequired), + z.literal(messages.passwordRequired), + ]), + ), + z.object({ + error: z.literal(messages.deviceIdRequired), + }), + z.object({ + code: z.literal('BadRequest'), + message: z.string().meta({ + example: messages.conflictingSensorsAndAddons, + }), + }), + ]) + .meta({ + id: 'DeviceBadRequestError', + description: + 'Bad request response. This route currently returns a few different bad-request shapes.', + }) + +const ForbiddenErrorSchema = z + .object({ + code: z.literal('Forbidden'), + message: z.literal(messages.invalidJwt), + }) + .meta({ + id: 'DeviceForbiddenError', + description: + 'Returned when the JWT authorization is invalid or missing for authenticated methods.', + }) + +const UnauthorizedErrorSchema = standardErrorResponseSchema( + 'Unauthorized', + z.literal(messages.passwordIncorrect), +).meta({ + id: 'DeviceUnauthorizedError', +}) + +const NotFoundErrorSchema = z + .union([ + standardErrorResponseSchema( + 'Not Found', + z.literal(messages.deviceNotFound), + ), + z.object({ + code: z.literal('NotFound'), + message: z.literal('Device not found'), + }), + ]) + .meta({ + id: 'DeviceNotFoundError', + description: + 'Device not found response. GET/DELETE and PUT currently use slightly different shapes.', + }) + +const MethodNotAllowedErrorSchema = z + .object({ + message: z.literal('Method Not Allowed'), + }) + .meta({ + id: 'MethodNotAllowedError', + }) + +const InternalServerErrorSchema = z + .union([ + standardErrorResponseSchema( + 'Internal Server Error', + z.string().meta({ + example: messages.internalDefault, + }), + ), + z.object({ + error: z.literal(messages.internalFetching), + }), + z.object({ + code: z.literal('InternalServerError'), + message: z.string().meta({ + example: 'Failed to update device', + }), + }), + ]) + .meta({ + id: 'DeviceInternalServerError', + description: + 'Internal server error response. This route currently returns different error shapes depending on the failing method.', + }) + +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: DeviceSchema, + }, + }, + }, + 400: { + description: 'Device ID is required', + content: { + 'application/json': { + schema: BadRequestErrorSchema, + }, + }, + }, + 404: { + description: 'Device not found', + content: { + 'application/json': { + schema: NotFoundErrorSchema, + }, + }, + }, + 500: { + description: 'Internal server error', + content: { + 'application/json': { + schema: InternalServerErrorSchema, + }, + }, + }, + }, + }, + + put: { + tags: ['Boxes'], + summary: 'Update device', + description: + 'Updates a device. Requires JWT authorization. Supports legacy addon behavior, image deletion, location updates, group tag updates, and sensor updates.', + 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: { + description: + 'Bad request. This can happen for conflicting parameters or validation errors.', + content: { + 'application/json': { + schema: BadRequestErrorSchema, + }, + }, + }, + 403: { + description: 'Invalid or missing JWT authorization', + content: { + 'application/json': { + schema: ForbiddenErrorSchema, + }, + }, + }, + 404: { + description: 'Device not found', + content: { + 'application/json': { + schema: NotFoundErrorSchema, + }, + }, + }, + 500: { + description: 'Internal server error', + content: { + 'application/json': { + schema: InternalServerErrorSchema, + }, + }, + }, + }, + }, + + 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: { + description: 'Bad request - missing device id or password', + content: { + 'application/json': { + schema: BadRequestErrorSchema, + }, + }, + }, + 401: { + description: 'Unauthorized - incorrect password', + content: { + 'application/json': { + schema: UnauthorizedErrorSchema, + }, + }, + }, + 403: { + description: 'Invalid or missing JWT authorization', + content: { + 'application/json': { + schema: ForbiddenErrorSchema, + }, + }, + }, + 404: { + description: 'Device not found', + content: { + 'application/json': { + schema: NotFoundErrorSchema, + }, + }, + }, + 500: { + description: 'Internal server error', + content: { + 'application/json': { + schema: InternalServerErrorSchema, + }, + }, + }, + }, + }, +} -/** - * @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 diff --git a/app/routes/api.boxes.ts b/app/routes/api.boxes.ts index 214678f0..0949c8ea 100644 --- a/app/routes/api.boxes.ts +++ b/app/routes/api.boxes.ts @@ -13,323 +13,426 @@ import { CreateBoxSchema, } from '~/services/devices-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 { z } from 'zod' +import { type ZodOpenApiPathItemObject } from 'zod-openapi' + +const messages = { + invalidJwt: 'Invalid JWT authorization. Please sign in to obtain new JWT.', + invalidJson: 'Invalid JSON in request body', + invalidRequestData: 'Invalid request data', + invalidFormat: 'Invalid format parameter', + methodNotAllowed: 'Method Not Allowed', + internal: + 'The server was unable to complete your request. Please try again later.', +} + +const standardErrorResponseSchema = ( + code: Code, + messageSchema: z.ZodType = z.string(), +) => + z.object({ + code: z.literal(code), + message: messageSchema, + error: messageSchema, + }) + +const BoxesQueryParamsSchema = BoxesQuerySchema.meta({ + id: 'BoxesQueryParams', + description: 'Query parameters used to filter and format the boxes response.', +}) + +const CreateBoxRequestSchema = CreateBoxSchema.meta({ + id: 'CreateBoxRequest', + description: 'Payload for creating a new box/device.', +}) + +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.', + }) + +const SensorSchema = 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.string().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: 'BoxSensor', + description: 'Sensor belonging to a box/device.', + }) + +const BoxSchema = z + .looseObject({ + _id: z.string().optional().meta({ + description: 'Unique box identifier in API format', + example: 'clx1234567890abcdef', + }), + id: z.string().optional().meta({ + description: 'Unique device identifier', + example: 'clx1234567890abcdef', + }), + name: z.string().meta({ + description: 'Box name', + example: 'My Weather Station', + }), + description: z.string().nullable().optional().meta({ + description: 'Box description', + example: 'A weather monitoring station', + }), + image: z.string().nullable().optional().meta({ + description: 'Box image URL', + example: 'https://example.com/image.jpg', + }), + link: z.string().nullable().optional().meta({ + description: 'Box website link', + example: 'https://example.com', + }), + grouptag: z + .array(z.string()) + .optional() + .meta({ + description: 'Box group tags', + example: ['weather', 'outdoor'], + }), + exposure: z.string().nullable().optional().meta({ + description: 'Box exposure type', + example: 'outdoor', + }), + model: z.string().nullable().optional().meta({ + description: 'Box model', + example: 'homeV2Wifi', + }), + latitude: z.number().nullable().optional().meta({ + description: 'Box latitude', + example: 52.520008, + }), + longitude: z.number().nullable().optional().meta({ + description: 'Box longitude', + example: 13.404954, + }), + useAuth: z.boolean().optional().meta({ + description: 'Whether box requires authentication', + example: true, + }), + public: z.boolean().optional().meta({ + description: 'Whether box is public', + example: false, + }), + status: z.string().nullable().optional().meta({ + description: 'Box status', + example: 'inactive', + }), + createdAt: z.string().datetime().optional().meta({ + description: 'Box creation timestamp', + example: '2024-01-15T10:30:00.000Z', + }), + updatedAt: z.string().datetime().optional().meta({ + description: 'Box last update timestamp', + example: '2024-01-15T10:30:00.000Z', + }), + expiresAt: z.string().datetime().nullable().optional().meta({ + description: 'Box 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.string().datetime().optional(), + }) + .optional() + .meta({ + description: 'Current location as GeoJSON Point-like object', + }), + lastMeasurementAt: z.string().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: 'Box integrations', + example: { + mqtt: { + enabled: false, + }, + }, + }), + sensors: z.array(SensorSchema).optional().meta({ + description: 'Sensors belonging to this box', + }), + }) + .meta({ + id: 'Box', + description: + 'Box/device object. The exact shape depends on whether the response is returned directly from the database or transformed through `transformDeviceToApiFormat`.', + }) + +const BoxesResponseSchema = z.array(BoxSchema).meta({ + id: 'BoxesResponse', + description: 'List of boxes/devices.', +}) + +const BoxesGeoJsonResponseSchema = z + .object({ + type: z.literal('FeatureCollection'), + features: z.array( + z.object({ + type: z.literal('Feature'), + geometry: GeoJsonPointSchema, + properties: BoxSchema, + }), + ), + }) + .meta({ + id: 'BoxesGeoJsonResponse', + description: + 'GeoJSON FeatureCollection of boxes. 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', + }, + }, + ], + }, + }) + +const CreatedBoxResponseSchema = BoxSchema.meta({ + id: 'CreatedBoxResponse', + description: + 'Created box/device response transformed through `transformDeviceToApiFormat`.', +}) + +const ValidationBadRequestErrorSchema = z + .object({ + code: z.literal('Bad Request'), + message: z.literal(messages.invalidRequestData), + errors: z.array(z.string()).meta({ + description: 'Validation errors returned by CreateBoxSchema', + example: [ + 'name: Required', + 'location: Expected array, received undefined', + ], + }), + }) + .meta({ + id: 'CreateBoxValidationError', + description: + 'Validation error response for invalid create-box request payloads.', + }) + +const BadRequestErrorSchema = z + .union([ + standardErrorResponseSchema('Bad Request', z.literal(messages.invalidJson)), + ValidationBadRequestErrorSchema, + ]) + .meta({ + id: 'BoxesBadRequestError', + description: + 'Bad request response. Invalid JSON uses the standard error shape; validation errors include an `errors` array.', + }) + +const ForbiddenErrorSchema = standardErrorResponseSchema( + 'Forbidden', + z.literal(messages.invalidJwt), +).meta({ + id: 'ForbiddenError', +}) + +const MethodNotAllowedErrorSchema = standardErrorResponseSchema( + 'Method Not Allowed', + z.literal(messages.methodNotAllowed), +).meta({ + id: 'MethodNotAllowedError', +}) + +const UnprocessableContentErrorSchema = standardErrorResponseSchema( + 'Unprocessable Content', + z.string().meta({ + example: messages.invalidFormat, + }), +).meta({ + id: 'UnprocessableContentError', +}) + +const InternalServerErrorSchema = standardErrorResponseSchema( + 'Internal Server Error', + z.literal(messages.internal), +).meta({ + id: 'InternalServerError', +}) + +export const openapi: ZodOpenApiPathItemObject = { + get: { + tags: ['Boxes'], + summary: 'Get boxes', + description: + 'Find boxes/devices using query parameters. By default, a JSON array of boxes is returned. If `format=geojson`, a GeoJSON FeatureCollection is returned.', + operationId: 'findBoxes', + + requestParams: { + query: BoxesQueryParamsSchema, + }, + + responses: { + 200: { + description: 'Boxes retrieved successfully', + content: { + 'application/json': { + schema: z.union([BoxesResponseSchema, BoxesGeoJsonResponseSchema]), + }, + }, + }, + 422: { + description: 'Invalid query parameter', + content: { + 'application/json': { + schema: UnprocessableContentErrorSchema, + }, + }, + }, + 500: { + description: 'Internal server error', + content: { + 'application/json': { + schema: InternalServerErrorSchema, + }, + }, + }, + }, + }, + + 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: { + 200: { + description: 'Boxes retrieved successfully', + content: { + 'application/json': { + schema: BoxesResponseSchema, + }, + 'application/geo+json': { + schema: BoxesGeoJsonResponseSchema, + }, + }, + }, + 201: { + description: 'Box created successfully', + content: { + 'application/json': { + schema: CreatedBoxResponseSchema, + }, + }, + }, + 400: { + description: + 'Bad request. This can happen when the request body is not valid JSON or does not match CreateBoxSchema.', + content: { + 'application/json': { + schema: BadRequestErrorSchema, + }, + }, + }, + 403: { + description: 'Forbidden - invalid or missing JWT token', + content: { + 'application/json': { + schema: ForbiddenErrorSchema, + }, + }, + }, + 405: { + description: 'Method not allowed - only POST is supported for actions', + content: { + 'application/json': { + schema: MethodNotAllowedErrorSchema, + }, + }, + }, + 500: { + description: 'Internal server error', + content: { + 'application/json': { + schema: InternalServerErrorSchema, + }, + }, + }, + }, + }, +} + export async function loader({ request }: Route.LoaderArgs) { const url = new URL(request.url) const queryObj = Object.fromEntries(url.searchParams) @@ -364,7 +467,11 @@ export async function loader({ request }: Route.LoaderArgs) { })), } - return geojson + return Response.json(geojson, { + headers: { + 'Content-Type': 'application/geo+json; charset=utf-8', + }, + }) } else { return devices } diff --git a/app/routes/api.users.me.ts b/app/routes/api.users.me.ts index b853dd37..546fb8dd 100644 --- a/app/routes/api.users.me.ts +++ b/app/routes/api.users.me.ts @@ -1,289 +1,352 @@ +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 { StandardResponse } from '~/lib/responses' import { deleteUser, updateUserDetails } from '~/services/user-service.server' +import { type Route } from './+types/api.users.me' + +const messages = { + invalidJwt: 'Invalid JWT authorization. Please sign in to obtain new JWT.', + internal: + 'The server was unable to complete your request. Please try again later.', + noChanges: 'No changed properties supplied. User remains unchanged.', + badRequest: 'Bad Request', + passwordIncorrect: 'Password incorrect', + currentPasswordRequired: + 'Current password is required when setting a new password', +} /** - * @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. + * During migration I would keep this loose, because jwtResponse / updatedUser + * may contain additional fields that your service layer still needs. + * + * Once you know the exact public response shape, you can switch this to + * z.object(...) for stricter output. */ +const UserSchema = z + .looseObject({ + id: z.string().meta({ + description: 'Unique user identifier', + example: 'user_123456', + }), + email: z.string().email().meta({ + description: "User's email address", + example: 'user@example.com', + }), + name: z.string().meta({ + description: "User's display name", + example: 'John Doe', + }), + language: z.string().meta({ + description: "User's preferred language", + example: 'en', + }), + role: z.string().optional().meta({ + description: "User's role", + example: 'user', + }), + emailIsConfirmed: z.boolean().optional().meta({ + description: "Whether the user's email address is confirmed", + example: true, + }), + createdAt: z.string().datetime().optional().meta({ + description: 'Account creation timestamp', + example: '2024-01-15T10:30:00Z', + }), + updatedAt: z.string().datetime().optional().meta({ + description: 'Last account update timestamp', + example: '2024-01-20T14:45:00Z', + }), + }) + .meta({ + id: 'User', + description: 'User profile information', + }) + +const UserWithBoxesSchema = UserSchema.extend({ + boxes: z.array(z.string()).meta({ + description: 'A list of ids of the users devices', + example: ['60a13611a877b3001b8ffd59', '5bdbe70f55d0ad001a04edc9'], + }), +}).meta({ + id: 'UserWithBoxes', + description: 'User profile information including device ids', +}) + +const GetMeResponseSchema = z + .object({ + code: z.literal('Ok').default('Ok'), + data: z.object({ + me: UserWithBoxesSchema, + }), + }) + .meta({ id: 'GetCurrentUserResponse' }) + +const PutRequestSchema = z + .object({ + email: z.string().trim().email().optional().meta({ + description: 'New email address', + example: 'newemail@example.com', + }), + language: z.string().trim().min(1).optional().meta({ + description: 'Preferred language setting', + example: 'en', + }), + name: z.string().trim().min(1).optional().meta({ + description: "User's display name", + example: 'John Doe', + }), + currentPassword: z.string().min(1).optional().meta({ + description: 'Current password, required for password changes', + example: 'currentPassword123', + format: 'password', + }), + newPassword: z.string().min(8).optional().meta({ + description: 'New password', + example: 'newPassword456', + format: 'password', + }), + }) + .superRefine((data, ctx) => { + if (data.newPassword && !data.currentPassword) { + ctx.addIssue({ + code: 'custom', + path: ['currentPassword'], + message: messages.currentPasswordRequired, + }) + } + }) + .meta({ id: 'UpdateCurrentUserRequest' }) + +const PutUpdatedResponseSchema = 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 PutNoChangesResponseSchema = z + .object({ + code: z.literal('Ok').default('Ok'), + message: z.literal(messages.noChanges).default(messages.noChanges), + }) + .meta({ + id: 'UpdateCurrentUserNoChangesResponse', + description: 'No changes made', + }) + +const PutResponseSchema = z.union([ + PutUpdatedResponseSchema, + PutNoChangesResponseSchema, +]) + +const DeleteRequestSchema = z + .object({ + password: z.string().min(1, messages.badRequest).meta({ + description: 'Current password for account deletion confirmation', + example: 'myCurrentPassword123', + format: 'password', + }), + }) + .meta({ id: 'DeleteCurrentUserRequest' }) + +const ForbiddenErrorSchema = z + .object({ + code: z.literal('Forbidden').default('Forbidden'), + message: z.literal(messages.invalidJwt).default(messages.invalidJwt), + error: z.literal(messages.invalidJwt).optional(), + }) + .meta({ id: 'ForbiddenError' }) + +const BadRequestErrorSchema = z + .object({ + code: z.literal('Bad Request').default('Bad Request'), + message: z.string().meta({ + example: 'Current password is incorrect', + }), + error: z.string().optional(), + }) + .meta({ id: 'BadRequestError' }) + +const UnauthorizedErrorSchema = z + .object({ + code: z.literal('Unauthorized').default('Unauthorized'), + message: z.literal(messages.passwordIncorrect), + error: z.literal(messages.passwordIncorrect).optional(), + }) + .meta({ id: 'UnauthorizedError' }) + +const InternalServerErrorSchema = z + .object({ + code: z.literal('Internal Server Error').default('Internal Server Error'), + message: z.literal(messages.internal).default(messages.internal), + error: z.literal(messages.internal).optional(), + }) + .meta({ id: 'InternalServerError' }) + +export const openapi: ZodOpenApiPathItemObject = { + 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: GetMeResponseSchema }, + }, + }, + 403: { + description: 'Invalid or missing JWT token', + content: { + 'application/json': { schema: ForbiddenErrorSchema }, + }, + }, + 500: { + description: 'Internal server error', + content: { + 'application/json': { schema: InternalServerErrorSchema }, + }, + }, + }, + }, + + 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: PutRequestSchema }, + }, + }, + responses: { + 200: { + description: 'User profile updated successfully or no changes made', + content: { + 'application/json': { schema: PutResponseSchema }, + }, + }, + 400: { + description: 'Bad request - validation errors', + content: { + 'application/json': { schema: BadRequestErrorSchema }, + }, + }, + 403: { + description: 'Invalid or missing JWT token', + content: { + 'application/json': { schema: ForbiddenErrorSchema }, + }, + }, + 500: { + description: 'Internal server error', + content: { + 'application/json': { schema: InternalServerErrorSchema }, + }, + }, + }, + }, + + 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: DeleteRequestSchema, + }, + }, + }, + responses: { + 200: { + description: 'Account successfully deleted', + content: { + 'application/json': { + schema: z.null().meta({ + description: 'Empty response indicating successful deletion', + }), + }, + }, + }, + 400: { + description: 'Bad request - missing password', + content: { + 'application/json': { schema: BadRequestErrorSchema }, + }, + }, + 401: { + description: 'Unauthorized - incorrect password', + content: { + 'application/json': { schema: UnauthorizedErrorSchema }, + }, + }, + 403: { + description: 'Invalid or missing JWT token', + content: { + 'application/json': { schema: ForbiddenErrorSchema }, + }, + }, + 500: { + description: 'Internal server error', + content: { + 'application/json': { schema: InternalServerErrorSchema }, + }, + }, + }, + }, +} + +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 loader = async ({ request }: Route.LoaderArgs) => { try { const jwtResponse = await getUserFromJwt(request) - if (typeof jwtResponse === 'string') - return StandardResponse.forbidden( - 'Invalid JWT authorization. Please sign in to obtain new JWT.', - ) + if (typeof jwtResponse === 'string') { + return StandardResponse.forbidden(messages.invalidJwt) + } const deviceIds = await getUserDeviceIds(jwtResponse.id) - return StandardResponse.ok({ + const responseParsed = await GetMeResponseSchema.safeParseAsync({ code: 'Ok', data: { me: { ...jwtResponse, boxes: deviceIds } }, }) + + if (!responseParsed.success) { + console.warn(responseParsed.error) + return StandardResponse.internalServerError() + } + + return StandardResponse.ok(responseParsed.data) } catch (err) { console.warn(err) return StandardResponse.internalServerError() @@ -294,6 +357,7 @@ 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 loaderValue.json()).data.me as User @@ -309,75 +373,108 @@ export const action = async ({ request }: Route.ActionArgs) => { } 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(' ') + let body: unknown - const { updated, messages, updatedUser } = await updateUserDetails( - user, - jwtString, - { - email, - language, - name, - currentPassword, - newPassword, - }, - ) - const messageText = messages.join('.') + try { + body = await request.json() + } catch { + return StandardResponse.badRequest(messages.badRequest) + } + + const requestParsed = await PutRequestSchema.safeParseAsync(body) + + if (!requestParsed.success) { + return StandardResponse.badRequest( + requestParsed.error.issues[0]?.message ?? messages.badRequest, + ) + } + + const jwtString = getBearerToken(request) + + if (!jwtString) { + return StandardResponse.forbidden(messages.invalidJwt) + } + + const { + updated, + messages: updateMessages, + updatedUser, + } = await updateUserDetails(user, jwtString, requestParsed.data) + + const messageText = updateMessages.join('.') if (updated === false) { - if (messages.length > 0) { + if (updateMessages.length > 0) { return StandardResponse.badRequest(messageText) } - return StandardResponse.ok({ + const responseParsed = await PutNoChangesResponseSchema.safeParseAsync({ code: 'Ok', - message: 'No changed properties supplied. User remains unchanged.', + message: messages.noChanges, }) + + if (!responseParsed.success) { + console.warn(responseParsed.error) + return StandardResponse.internalServerError() + } + + return StandardResponse.ok(responseParsed.data) } - return StandardResponse.ok({ + const responseParsed = await PutUpdatedResponseSchema.safeParseAsync({ code: 'Ok', message: `User successfully saved. ${messageText}`, data: { me: updatedUser }, }) + + if (!responseParsed.success) { + console.warn(responseParsed.error) + return StandardResponse.internalServerError() + } + + 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() + let formData: FormData + try { - formData = await r.formData() + formData = await request.formData() } catch { - // Just continue, it will fail in the next check + return StandardResponse.badRequest(messages.badRequest) } - if ( - !formData.has('password') || - formData.get('password')?.toString().length === 0 + const requestParsed = DeleteRequestSchema.safeParse( + Object.fromEntries(formData.entries()), ) - return StandardResponse.badRequest('Bad Request') - const rawAuthorizationHeader = r.headers.get('authorization') - if (!rawAuthorizationHeader) throw new Error('no_token') - const [, jwtString] = rawAuthorizationHeader.split(' ') + if (!requestParsed.success) { + return StandardResponse.badRequest( + requestParsed.error.issues[0]?.message ?? messages.badRequest, + ) + } + + const jwtString = getBearerToken(request) + + if (!jwtString) { + return StandardResponse.forbidden(messages.invalidJwt) + } const deleted = await deleteUser( user, - formData.get('password')!.toString(), // ! operator is fine, we check formData.has above + requestParsed.data.password, jwtString, ) - if (deleted === 'unauthorized') - return StandardResponse.unauthorized('Password incorrect') + if (deleted === 'unauthorized') { + return StandardResponse.unauthorized(messages.passwordIncorrect) + } return StandardResponse.ok(null) } catch (err) { diff --git a/app/routes/api.users.refresh-auth.ts b/app/routes/api.users.refresh-auth.ts index f827f6d5..953e6e01 100644 --- a/app/routes/api.users.refresh-auth.ts +++ b/app/routes/api.users.refresh-auth.ts @@ -3,113 +3,175 @@ 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' -/** - * @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.', + internal: + 'The server was unable to complete your request. Please try again later.', +} + +const standardErrorResponseSchema = ( + code: Code, + messageSchema: z.ZodType = z.string(), +) => + z.object({ + code: z.literal(code), + message: messageSchema, + error: messageSchema, + }) + +const RefreshAuthRequestSchema = z + .object({ + token: z.string().trim().min(1, errorMessages.tokenRequired).meta({ + description: 'Valid refresh token', + example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', + }), + }) + .meta({ + id: 'RefreshAuthRequest', + description: 'Refresh authentication request body.', + }) + +const UserSchema = z + .looseObject({ + id: z.string().meta({ + description: 'Unique user identifier', + example: 'user_123456', + }), + email: z.string().email().meta({ + description: "User's email address", + example: 'user@example.com', + }), + name: z.string().meta({ + description: "User's display name", + example: 'John Doe', + }), + language: z.string().optional().meta({ + description: "User's preferred language", + example: 'en', + }), + role: z.string().optional().meta({ + description: "User's role", + example: 'user', + }), + emailIsConfirmed: z.boolean().optional().meta({ + description: "Whether the user's email address is confirmed", + example: true, + }), + createdAt: z.string().datetime().optional().meta({ + description: 'Account creation timestamp', + example: '2024-01-15T10:30:00.000Z', + }), + updatedAt: z.string().datetime().optional().meta({ + description: 'Last account update timestamp', + example: '2024-01-20T14:45:00.000Z', + }), + }) + .meta({ + id: 'User', + description: 'User information object.', + }) + +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: z.jwt({ alg: 'HS256' }).meta({ + description: 'New JWT access token', + example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', + }), + refreshToken: z.string().meta({ + description: 'New refresh token', + example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', + }), + }) + .meta({ + id: 'RefreshAuthResponse', + description: 'Successfully refreshed authentication response.', + }) + +const ForbiddenErrorSchema = standardErrorResponseSchema( + 'Forbidden', + 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', + }), + ]), +).meta({ + id: 'RefreshAuthForbiddenError', + description: + 'Authentication failed because the refresh token is missing, invalid, expired, or the request body could not be parsed.', +}) + +const InternalServerErrorSchema = standardErrorResponseSchema( + 'Internal Server Error', + z.string().meta({ + example: errorMessages.internal, + }), +).meta({ + id: 'InternalServerError', +}) + +export const openapi: ZodOpenApiPathItemObject = { + post: { + tags: ['Authentication'], + summary: 'Refresh authentication token', + description: + 'Refreshes a JWT access token using a valid refresh token. The current access token must be supplied via the Authorization 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: { + description: + 'Authentication failed - missing, invalid, expired, or malformed refresh token request', + content: { + 'application/json': { + schema: ForbiddenErrorSchema, + }, + }, + }, + 500: { + description: 'Internal server error', + content: { + 'application/json': { + schema: InternalServerErrorSchema, + }, + }, + }, + }, + }, +} export const action = async ({ request }: Route.ActionArgs) => { try { diff --git a/app/routes/api.users.sign-in.ts b/app/routes/api.users.sign-in.ts index c2c1ab2c..e1cd1a23 100644 --- a/app/routes/api.users.sign-in.ts +++ b/app/routes/api.users.sign-in.ts @@ -1,143 +1,139 @@ -import { z } from 'zod' -import { type Route } from './+types/api.users.sign-in' -import { StandardResponse } from '~/lib/responses' -import { signIn } from '~/services/user-service.server' -import { ZodOpenApiPathItemObject } from 'zod-openapi' +import { z } from "zod"; +import { type Route } from "./+types/api.users.sign-in"; +import { StandardResponse } from "~/lib/responses"; +import { signIn } from "~/services/user-service.server"; +import { type ZodOpenApiPathItemObject } from "zod-openapi"; import { - requestContentTypeJson, - responseContentTypeJson, -} from '~/middleware/content-type-header.server' + requestContentTypeJson, + responseContentTypeJson, +} from "~/middleware/content-type-header.server"; const errorMessages = { - email: 'You must specify either your email or your username', - password: 'You must specify your password to sign in', - userAndOrPassword: 'User and or password not valid!', -} + 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', - }), -}) + 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 PostResponseSchema = z.object({ - data: z.object( - { - user: z.object({ - name: z.string(), - ...PostRequestSchema.pick({ email: true }).shape, - role: z.string(), - language: z.string(), - emailIsConfirmed: z.boolean(), - boxes: z.array(z.string()).meta({ - description: 'A list of ids of the users devices', - example: ['60a13611a877b3001b8ffd59', '5bdbe70f55d0ad001a04edc9'], - }), - }), - }, - errorMessages.userAndOrPassword, - ), - token: z.jwt({ alg: 'HS256', error: errorMessages.userAndOrPassword }).meta({ - description: 'valid json web token', - }), - refreshToken: z.string(errorMessages.userAndOrPassword).meta({ - description: 'valid json web token', - }), - code: z.literal('Authorized').default('Authorized'), - message: z - .literal('Successfully signed in') - .default('Successfully signed in'), -}) + data: z.object( + { + user: z.object({ + name: z.string(), + ...PostRequestSchema.pick({ email: true }).shape, + role: z.string(), + language: z.string(), + emailIsConfirmed: z.boolean(), + boxes: z.array(z.string()).meta({ + description: "A list of ids of the users devices", + example: ["60a13611a877b3001b8ffd59", "5bdbe70f55d0ad001a04edc9"], + }), + }), + }, + errorMessages.userAndOrPassword, + ), + token: z.jwt({ alg: "HS256", error: errorMessages.userAndOrPassword }).meta({ + description: "valid json web token", + }), + refreshToken: z.string(errorMessages.userAndOrPassword).meta({ + description: "valid json web token", + }), + code: z.literal("Authorized").default("Authorized"), + message: z.literal("Successfully signed in").default("Successfully signed in"), +}); export const openapi: ZodOpenApiPathItemObject = { - post: { - tags: ['Auth'], - summary: 'Sign in using email or name and password', - requestBody: { - required: true, - content: { - 'application/json': { schema: PostRequestSchema }, - }, - }, - responses: { - 200: { - description: 'Signed in', - content: { - 'application/json': { schema: PostResponseSchema }, - }, - }, - 403: { - description: 'Unauthorized', - content: { - 'application/json': { - schema: z.object({ - code: z.literal('Forbidden'), - message: z.xor([ - z.literal(errorMessages.email), - z.literal(errorMessages.password), - z.literal(errorMessages.userAndOrPassword), - ]), - error: z.xor([ - z.literal(errorMessages.email), - z.literal(errorMessages.password), - z.literal(errorMessages.userAndOrPassword), - ]), - }), - }, - }, - }, - 500: { - description: 'Internal Server Error', - content: { - 'application/json': { - schema: z.object({ - code: z.literal('Internal Server Error'), - message: z.literal( - 'The server was unable to complete your request. Please try again later.', - ), - error: z.literal( - 'The server was unable to complete your request. Please try again later.', - ), - }), - }, - }, - }, - }, - }, -} + post: { + tags: ["Auth"], + summary: "Sign in using email or name and password", + requestBody: { + required: true, + content: { + "application/json": { schema: PostRequestSchema }, + }, + }, + responses: { + 200: { + description: "Signed in", + content: { + "application/json": { schema: PostResponseSchema }, + }, + }, + 403: { + description: "Unauthorized", + content: { + "application/json": { + schema: z.object({ + code: z.literal("Forbidden"), + message: z.xor([ + z.literal(errorMessages.email), + z.literal(errorMessages.password), + z.literal(errorMessages.userAndOrPassword), + ]), + error: z.xor([ + z.literal(errorMessages.email), + z.literal(errorMessages.password), + z.literal(errorMessages.userAndOrPassword), + ]), + }), + }, + }, + }, + 500: { + description: "Internal Server Error", + content: { + "application/json": { + schema: z.object({ + code: z.literal("Internal Server Error"), + message: z.literal( + "The server was unable to complete your request. Please try again later.", + ), + error: z.literal( + "The server was unable to complete your request. Please try again later.", + ), + }), + }, + }, + }, + }, + }, +}; export const middleware: Route.MiddlewareFunction[] = [ - requestContentTypeJson, - responseContentTypeJson, -] + 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) + 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)) || {} + const { email, password } = requestParsed.data; + const { user, jwt, refreshToken } = (await signIn(email, password)) || {}; - const responseParsed = await PostResponseSchema.safeParseAsync({ - data: { user }, - token: jwt, - refreshToken, - }) - if (!responseParsed.success) - return StandardResponse.forbidden(responseParsed.error.issues[0].message) + const responseParsed = await PostResponseSchema.safeParseAsync({ + data: { user }, + token: jwt, + refreshToken, + }); + if (!responseParsed.success) + return StandardResponse.forbidden(responseParsed.error.issues[0].message); - return StandardResponse.ok(responseParsed.data) - } catch (error) { - console.warn(error) - return StandardResponse.internalServerError() - } -} + return StandardResponse.ok(responseParsed.data); + } catch (error) { + console.warn(error); + return StandardResponse.internalServerError(); + } +}; From a1d39acdef2e55a9470dd4f7625ff43169b6848f Mon Sep 17 00:00:00 2001 From: jona159 Date: Wed, 20 May 2026 15:21:04 +0200 Subject: [PATCH 07/29] feat: add abstraction layer for openapi errors and responses --- app/lib/openapi/responses/errors.ts | 46 +++++++++++ app/lib/openapi/schemas/common.ts | 12 +++ app/lib/openapi/schemas/errors.ts | 71 ++++++++++++++++ app/routes/api.boxes.$deviceId.locations.ts | 91 +++++++++------------ 4 files changed, 168 insertions(+), 52 deletions(-) create mode 100644 app/lib/openapi/responses/errors.ts create mode 100644 app/lib/openapi/schemas/common.ts create mode 100644 app/lib/openapi/schemas/errors.ts diff --git a/app/lib/openapi/responses/errors.ts b/app/lib/openapi/responses/errors.ts new file mode 100644 index 00000000..a9c6f31a --- /dev/null +++ b/app/lib/openapi/responses/errors.ts @@ -0,0 +1,46 @@ +import type { ZodType } from 'zod/v4' + +export const jsonErrorResponse = (description: string, schema: ZodType) => ({ + description, + content: { + 'application/json': { + 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) diff --git a/app/lib/openapi/schemas/common.ts b/app/lib/openapi/schemas/common.ts new file mode 100644 index 00000000..f7a21a17 --- /dev/null +++ b/app/lib/openapi/schemas/common.ts @@ -0,0 +1,12 @@ +import * as z from 'zod/v4' +import 'zod-openapi' + +export const IdParamSchema = z.string().min(1).meta({ + description: 'Resource ID', + example: '5f2a1b2c3d4e5f6a7b8c9d0e', +}) + +export const IsoDateTimeSchema = z.string().datetime().meta({ + description: 'ISO 8601 timestamp', + example: '2026-05-18T12:34:56.000Z', +}) diff --git a/app/lib/openapi/schemas/errors.ts b/app/lib/openapi/schemas/errors.ts new file mode 100644 index 00000000..82a43a29 --- /dev/null +++ b/app/lib/openapi/schemas/errors.ts @@ -0,0 +1,71 @@ +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, + }) + +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 createInternalServerErrorSchema = createStandardErrorSchemaFactory( + 'Internal Server Error', +) diff --git a/app/routes/api.boxes.$deviceId.locations.ts b/app/routes/api.boxes.$deviceId.locations.ts index 5892ea56..cc95c34b 100644 --- a/app/routes/api.boxes.$deviceId.locations.ts +++ b/app/routes/api.boxes.$deviceId.locations.ts @@ -5,6 +5,16 @@ 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' +import { + badRequestResponse, + internalServerErrorResponse, + notFoundResponse, +} from '~/lib/openapi/responses/errors' +import { + createBadRequestErrorSchema, + createInternalServerErrorSchema, + createNotFoundErrorSchema, +} from '~/lib/openapi/schemas/errors' const messages = { invalidDeviceId: 'Invalid device id specified', @@ -13,16 +23,6 @@ const messages = { 'The server was unable to complete your request. Please try again later.', } -const standardErrorResponseSchema = ( - code: Code, - messageSchema: z.ZodType = z.string(), -) => - z.object({ - code: z.literal(code), - message: messageSchema, - error: messageSchema, - }) - const DeviceLocationsPathParamsSchema = z.object({ deviceId: z.string().min(1).meta({ description: 'The ID of the device you are referring to', @@ -132,24 +132,24 @@ const GeoJsonLineStringResponseSchema = z }, }) -const BadRequestErrorSchema = standardErrorResponseSchema( - 'Bad Request', - z.string().meta({ - example: messages.invalidDeviceId, - }), -).meta({ id: 'BadRequestError' }) +const BadRequestErrorSchema = createBadRequestErrorSchema({ + id: 'DeviceLocationsBadRequestError', + description: + 'Bad request. This can happen for an invalid device id, invalid date parameter, or invalid format parameter.', + examples: [messages.invalidDeviceId], +}) -const NotFoundErrorSchema = standardErrorResponseSchema( - 'Not Found', - z.literal(messages.deviceNotFound), -).meta({ id: 'NotFoundError' }) +const NotFoundErrorSchema = createNotFoundErrorSchema({ + id: 'DeviceLocationsNotFoundError', + description: 'Returned when the requested device does not exist.', + messageSchema: z.literal(messages.deviceNotFound), +}) -const InternalServerErrorSchema = standardErrorResponseSchema( - 'Internal Server Error', - z.string().meta({ - example: messages.internal, - }), -).meta({ id: 'InternalServerError' }) +const InternalServerErrorSchema = createInternalServerErrorSchema({ + id: 'DeviceLocationsInternalServerError', + description: 'Returned when the server cannot complete the request.', + examples: [messages.internal], +}) export const openapi: ZodOpenApiPathItemObject = { get: { @@ -166,7 +166,7 @@ export const openapi: ZodOpenApiPathItemObject = { responses: { 200: { - description: 'Success', + description: 'Device locations returned successfully.', content: { 'application/json': { schema: JsonLocationsResponseSchema, @@ -176,31 +176,18 @@ export const openapi: ZodOpenApiPathItemObject = { }, }, }, - 400: { - description: - 'Bad request. This can happen for an invalid device id, invalid date parameter, or invalid format parameter.', - content: { - 'application/json': { - schema: BadRequestErrorSchema, - }, - }, - }, - 404: { - description: 'Device not found', - content: { - 'application/json': { - schema: NotFoundErrorSchema, - }, - }, - }, - 500: { - description: 'Internal server error', - content: { - 'application/json': { - schema: InternalServerErrorSchema, - }, - }, - }, + + 400: badRequestResponse( + BadRequestErrorSchema, + '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.', + ), }, }, } From 6bb4cc8ffe4907c34cbe0b2603e1be4c8f3a965c Mon Sep 17 00:00:00 2001 From: jona159 Date: Thu, 21 May 2026 10:18:30 +0200 Subject: [PATCH 08/29] feat: centralize openapi schemas, messages etc. --- .../errors.ts => errors/factories.ts} | 14 +- app/lib/openapi/errors/index.ts | 3 + .../errors.ts => errors/responses.ts} | 5 + app/lib/openapi/errors/schemas.ts | 64 ++++++ app/lib/openapi/messages.ts | 13 ++ app/lib/openapi/schemas/common.ts | 15 +- app/lib/openapi/schemas/device.ts | 211 ++++++++++++++++++ 7 files changed, 309 insertions(+), 16 deletions(-) rename app/lib/openapi/{schemas/errors.ts => errors/factories.ts} (80%) create mode 100644 app/lib/openapi/errors/index.ts rename app/lib/openapi/{responses/errors.ts => errors/responses.ts} (89%) create mode 100644 app/lib/openapi/errors/schemas.ts create mode 100644 app/lib/openapi/messages.ts create mode 100644 app/lib/openapi/schemas/device.ts diff --git a/app/lib/openapi/schemas/errors.ts b/app/lib/openapi/errors/factories.ts similarity index 80% rename from app/lib/openapi/schemas/errors.ts rename to app/lib/openapi/errors/factories.ts index 82a43a29..abcf01f4 100644 --- a/app/lib/openapi/schemas/errors.ts +++ b/app/lib/openapi/errors/factories.ts @@ -1,15 +1,7 @@ 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, - }) +import { standardErrorResponseSchema } from './schemas' type StandardErrorSchemaOptions = { code: Code @@ -65,7 +57,3 @@ export const createUnprocessableContentErrorSchema = export const createUnsupportedMediaTypeErrorSchema = createStandardErrorSchemaFactory('Unsupported Media Type') - -export const createInternalServerErrorSchema = createStandardErrorSchemaFactory( - 'Internal Server Error', -) diff --git a/app/lib/openapi/errors/index.ts b/app/lib/openapi/errors/index.ts new file mode 100644 index 00000000..adf838c4 --- /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/responses/errors.ts b/app/lib/openapi/errors/responses.ts similarity index 89% rename from app/lib/openapi/responses/errors.ts rename to app/lib/openapi/errors/responses.ts index a9c6f31a..f25abe92 100644 --- a/app/lib/openapi/responses/errors.ts +++ b/app/lib/openapi/errors/responses.ts @@ -44,3 +44,8 @@ export const internalServerErrorResponse = ( schema: ZodType, description = 'Internal server error.', ) => jsonErrorResponse(description, schema) + +export const methodNotAllowedResponse = ( + schema: ZodType, + description = 'Method not allowed.', +) => jsonErrorResponse(description, schema) diff --git a/app/lib/openapi/errors/schemas.ts b/app/lib/openapi/errors/schemas.ts new file mode 100644 index 00000000..e8db9013 --- /dev/null +++ b/app/lib/openapi/errors/schemas.ts @@ -0,0 +1,64 @@ +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 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 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', +}) diff --git a/app/lib/openapi/messages.ts b/app/lib/openapi/messages.ts new file mode 100644 index 00000000..7437fb3f --- /dev/null +++ b/app/lib/openapi/messages.ts @@ -0,0 +1,13 @@ +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', +} as const diff --git a/app/lib/openapi/schemas/common.ts b/app/lib/openapi/schemas/common.ts index f7a21a17..bd2a2ff3 100644 --- a/app/lib/openapi/schemas/common.ts +++ b/app/lib/openapi/schemas/common.ts @@ -1,9 +1,18 @@ import * as z from 'zod/v4' import 'zod-openapi' -export const IdParamSchema = z.string().min(1).meta({ - description: 'Resource ID', - example: '5f2a1b2c3d4e5f6a7b8c9d0e', +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 IsoDateTimeSchema = z.string().datetime().meta({ diff --git a/app/lib/openapi/schemas/device.ts b/app/lib/openapi/schemas/device.ts new file mode 100644 index 00000000..41d8748e --- /dev/null +++ b/app/lib/openapi/schemas/device.ts @@ -0,0 +1,211 @@ +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.string().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 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.string().datetime().optional().meta({ + description: 'Device creation timestamp', + example: '2024-01-15T10:30:00.000Z', + }), + updatedAt: z.string().datetime().optional().meta({ + description: 'Device last update timestamp', + example: '2024-01-15T10:30:00.000Z', + }), + expiresAt: z.string().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.string().datetime().optional(), + }) + .optional() + .meta({ + description: 'Current location as GeoJSON Point-like object', + }), + lastMeasurementAt: z.string().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', + }, + }, + ], + }, + }) From be3731d8b7a09a14cb1b6fc92043c7e401b5d063 Mon Sep 17 00:00:00 2001 From: jona159 Date: Thu, 21 May 2026 10:19:23 +0200 Subject: [PATCH 09/29] fix: api route paths --- app/routes/{api.claim.ts => api.boxes.claim.ts} | 2 +- ...nsfer.$deviceId.ts => api.boxes.transfer.$deviceId.ts} | 2 +- app/routes/{api.transfer.ts => api.boxes.transfer.ts} | 2 +- tests/routes/api.claim.spec.ts | 8 ++++---- tests/routes/api.transfers.spec.ts | 8 ++++---- 5 files changed, 11 insertions(+), 11 deletions(-) rename app/routes/{api.claim.ts => api.boxes.claim.ts} (96%) rename app/routes/{api.transfer.$deviceId.ts => api.boxes.transfer.$deviceId.ts} (98%) rename app/routes/{api.transfer.ts => api.boxes.transfer.ts} (98%) diff --git a/app/routes/api.claim.ts b/app/routes/api.boxes.claim.ts similarity index 96% rename from app/routes/api.claim.ts rename to app/routes/api.boxes.claim.ts index 8042c7ff..78d8a44e 100644 --- a/app/routes/api.claim.ts +++ b/app/routes/api.boxes.claim.ts @@ -1,4 +1,4 @@ -import { type Route } from './+types/api.claim' +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' diff --git a/app/routes/api.transfer.$deviceId.ts b/app/routes/api.boxes.transfer.$deviceId.ts similarity index 98% rename from app/routes/api.transfer.$deviceId.ts rename to app/routes/api.boxes.transfer.$deviceId.ts index 75434bf8..064266ac 100644 --- a/app/routes/api.transfer.$deviceId.ts +++ b/app/routes/api.boxes.transfer.$deviceId.ts @@ -1,4 +1,4 @@ -import { type Route } from './+types/api.transfer.$deviceId' +import { type Route } from './+types/api.boxes.transfer.$deviceId' import { getUserFromJwt } from '~/lib/jwt' import { StandardResponse } from '~/lib/responses' import { diff --git a/app/routes/api.transfer.ts b/app/routes/api.boxes.transfer.ts similarity index 98% rename from app/routes/api.transfer.ts rename to app/routes/api.boxes.transfer.ts index b286f11b..42527217 100644 --- a/app/routes/api.transfer.ts +++ b/app/routes/api.boxes.transfer.ts @@ -1,4 +1,4 @@ -import { type Route } from './+types/api.transfer' +import { type Route } from './+types/api.boxes.transfer' import { getUserFromJwt } from '~/lib/jwt' import { StandardResponse } from '~/lib/responses' import { diff --git a/tests/routes/api.claim.spec.ts b/tests/routes/api.claim.spec.ts index 217e2fcb..c352a817 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 caea9ce3..28ed2ebd 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 = { From 4afeb45005ef26b8adfe9fc29a5dd842a7c596a4 Mon Sep 17 00:00:00 2001 From: jona159 Date: Thu, 21 May 2026 12:59:52 +0200 Subject: [PATCH 10/29] feat: document stats endpoint --- app/routes/api.stats.ts | 95 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/app/routes/api.stats.ts b/app/routes/api.stats.ts index 292992f6..03990223 100644 --- a/app/routes/api.stats.ts +++ b/app/routes/api.stats.ts @@ -2,6 +2,101 @@ 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 'zod-openapi' +import { type ZodOpenApiPathItemObject } from 'zod-openapi' + +import { + InternalServerErrorSchema, + createBadRequestErrorSchema, +} from '~/lib/openapi/errors' + +import { + 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.', + ), + }, + }, +} + export async function loader({ request }: Route.LoaderArgs) { try { const url = new URL(request.url) From 1e5558235f9c501c67f85146e673b71ac141122f Mon Sep 17 00:00:00 2001 From: jona159 Date: Thu, 21 May 2026 13:00:22 +0200 Subject: [PATCH 11/29] fix: measurements instead of sensors as second stats value, as in old osem api --- app/services/statistics-service.server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/services/statistics-service.server.ts b/app/services/statistics-service.server.ts index 73b06b5b..cbd80691 100644 --- a/app/services/statistics-service.server.ts +++ b/app/services/statistics-service.server.ts @@ -33,7 +33,7 @@ export const getStatistics = async (humanReadable: boolean = false) => { const results = await Promise.all([ rowCount('device'), - rowCount('sensor'), + rowCount('measurement'), rowCountTimeBucket(measurement, 'time', 60000), ]) From 90d69f471136125704e98e43b0df9a6a70997b9c Mon Sep 17 00:00:00 2001 From: jona159 Date: Thu, 21 May 2026 13:01:29 +0200 Subject: [PATCH 12/29] feat: add openapi schemas, factories, responses --- app/lib/openapi/errors/factories.ts | 2 + app/lib/openapi/errors/responses.ts | 3 + app/lib/openapi/errors/schemas.ts | 20 +++++++ app/lib/openapi/schemas/auth.ts | 22 +++++++ app/lib/openapi/schemas/claim.ts | 42 +++++++++++++ app/lib/openapi/schemas/common.ts | 8 +++ app/lib/openapi/schemas/device.ts | 70 ++++++++++++++++++++-- app/lib/openapi/schemas/measurement.ts | 56 ++++++++++++++++++ app/lib/openapi/schemas/sensor.ts | 81 ++++++++++++++++++++++++++ app/lib/openapi/schemas/user.ts | 79 +++++++++++++++++++++++++ 10 files changed, 377 insertions(+), 6 deletions(-) create mode 100644 app/lib/openapi/schemas/auth.ts create mode 100644 app/lib/openapi/schemas/claim.ts create mode 100644 app/lib/openapi/schemas/measurement.ts create mode 100644 app/lib/openapi/schemas/sensor.ts create mode 100644 app/lib/openapi/schemas/user.ts diff --git a/app/lib/openapi/errors/factories.ts b/app/lib/openapi/errors/factories.ts index abcf01f4..b4c68d09 100644 --- a/app/lib/openapi/errors/factories.ts +++ b/app/lib/openapi/errors/factories.ts @@ -57,3 +57,5 @@ export const createUnprocessableContentErrorSchema = export const createUnsupportedMediaTypeErrorSchema = createStandardErrorSchemaFactory('Unsupported Media Type') + +export const createGoneErrorSchema = createStandardErrorSchemaFactory('Gone') diff --git a/app/lib/openapi/errors/responses.ts b/app/lib/openapi/errors/responses.ts index f25abe92..91009170 100644 --- a/app/lib/openapi/errors/responses.ts +++ b/app/lib/openapi/errors/responses.ts @@ -49,3 +49,6 @@ 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 index e8db9013..69d7ed00 100644 --- a/app/lib/openapi/errors/schemas.ts +++ b/app/lib/openapi/errors/schemas.ts @@ -32,6 +32,16 @@ export const UnprocessableContentErrorSchema = standardErrorResponseSchema( 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.' }), @@ -62,3 +72,13 @@ export const MethodNotAllowedErrorSchema = standardErrorResponseSchema( ).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/schemas/auth.ts b/app/lib/openapi/schemas/auth.ts new file mode 100644 index 00000000..cf839c48 --- /dev/null +++ b/app/lib/openapi/schemas/auth.ts @@ -0,0 +1,22 @@ +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.', + }) diff --git a/app/lib/openapi/schemas/claim.ts b/app/lib/openapi/schemas/claim.ts new file mode 100644 index 00000000..0a07c133 --- /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 index bd2a2ff3..d8178d6c 100644 --- a/app/lib/openapi/schemas/common.ts +++ b/app/lib/openapi/schemas/common.ts @@ -15,6 +15,14 @@ export const SensorIdSchema = z.string().min(1).meta({ example: '60a13611a877b3001b8ffd59', }) +export const DeviceSensorPathParamsSchema = z.object({ + deviceId: DeviceIdSchema.meta({ + description: + 'Unique identifier of the device. This parameter is kept for legacy route compatibility.', + }), + sensorId: SensorIdSchema, +}) + export const IsoDateTimeSchema = z.string().datetime().meta({ description: 'ISO 8601 timestamp', example: '2026-05-18T12:34:56.000Z', diff --git a/app/lib/openapi/schemas/device.ts b/app/lib/openapi/schemas/device.ts index 41d8748e..30d8767c 100644 --- a/app/lib/openapi/schemas/device.ts +++ b/app/lib/openapi/schemas/device.ts @@ -38,7 +38,7 @@ export const ApiSensorSchema = z }), lastMeasurement: z .object({ - createdAt: z.string().datetime().optional().meta({ + createdAt: z.iso.datetime().optional().meta({ example: '2023-01-01T00:00:00.000Z', }), value: z.union([z.string(), z.number()]).nullable().optional().meta({ @@ -53,6 +53,64 @@ export const ApiSensorSchema = z 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 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({ @@ -114,15 +172,15 @@ export const ApiDeviceSchema = z description: 'Device status', example: 'inactive', }), - createdAt: z.string().datetime().optional().meta({ + createdAt: z.iso.datetime().optional().meta({ description: 'Device creation timestamp', example: '2024-01-15T10:30:00.000Z', }), - updatedAt: z.string().datetime().optional().meta({ + updatedAt: z.iso.datetime().optional().meta({ description: 'Device last update timestamp', example: '2024-01-15T10:30:00.000Z', }), - expiresAt: z.string().datetime().nullable().optional().meta({ + expiresAt: z.iso.datetime().nullable().optional().meta({ description: 'Device expiration date', example: '2024-12-31T23:59:59.000Z', }), @@ -138,13 +196,13 @@ export const ApiDeviceSchema = z .object({ type: z.literal('Point'), coordinates: z.tuple([z.number(), z.number()]), - timestamp: z.string().datetime().optional(), + timestamp: z.iso.datetime().optional(), }) .optional() .meta({ description: 'Current location as GeoJSON Point-like object', }), - lastMeasurementAt: z.string().datetime().nullable().optional().meta({ + lastMeasurementAt: z.iso.datetime().nullable().optional().meta({ description: 'Last measurement timestamp', example: '2023-01-01T00:00:00.000Z', }), diff --git a/app/lib/openapi/schemas/measurement.ts b/app/lib/openapi/schemas/measurement.ts new file mode 100644 index 00000000..37b52ff5 --- /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 00000000..d9390d4b --- /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 00000000..c88721d8 --- /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.', +}) From 9d65d2534610aee95ab65549e240130bf5144c92 Mon Sep 17 00:00:00 2001 From: jona159 Date: Thu, 21 May 2026 13:54:39 +0200 Subject: [PATCH 13/29] feat: document api routes --- ....boxes.$deviceId.$sensorId.measurements.ts | 231 ++++++--- .../api.boxes.$deviceId.data.$sensorId.ts | 134 ++---- app/routes/api.boxes.$deviceId.locations.ts | 60 +-- app/routes/api.boxes.$deviceId.script.ts | 202 ++++++++ app/routes/api.boxes.$deviceId.sensors.ts | 225 ++------- app/routes/api.boxes.$deviceId.ts | 439 +++++------------- app/routes/api.boxes.claim.ts | 160 ++++++- app/routes/api.boxes.transfer.$deviceId.ts | 178 +++++++ app/routes/api.boxes.transfer.ts | 202 ++++++++ app/routes/api.boxes.ts | 400 +++------------- app/routes/api.users.me.boxes.$deviceId.ts | 78 ++++ .../api.users.me.resend-email-confirmation.ts | 78 +++- app/routes/api.users.me.ts | 350 ++++++-------- app/routes/api.users.password-reset.ts | 121 ++++- app/routes/api.users.refresh-auth.ts | 137 ++---- app/routes/api.users.register.ts | 195 ++++++++ .../api.users.request-password-reset.ts | 98 +++- 17 files changed, 1944 insertions(+), 1344 deletions(-) diff --git a/app/routes/api.boxes.$deviceId.$sensorId.measurements.ts b/app/routes/api.boxes.$deviceId.$sensorId.measurements.ts index 535bf59e..85fda3cc 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,158 @@ import { 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 { DeviceSensorPathParamsSchema } from '~/lib/openapi/schemas/common' + +import { + ForbiddenErrorSchema, + InternalServerErrorSchema, + MethodNotAllowedErrorSchema, + NotFoundErrorSchema, + createBadRequestErrorSchema, +} from '~/lib/openapi/errors' + +import { + badRequestResponse, + forbiddenResponse, + internalServerErrorResponse, + methodNotAllowedResponse, + notFoundResponse, +} from '~/lib/openapi/errors' + +const DeleteSensorMeasurementsQueryParamsSchema = z + .object({ + 'from-date': z.iso.datetime().optional().meta({ + description: + 'Beginning date of the measurement range to delete. Must be used together with `to-date`.', + example: '2026-05-13T12:00:00.000Z', + }), + + 'to-date': z.iso.datetime().optional().meta({ + description: + 'End date of the measurement range to delete. Must be used together with `from-date`.', + example: '2026-05-15T12:00:00.000Z', + }), + + timestamps: z + .union([z.iso.datetime(), z.array(z.iso.datetime())]) + .optional() + .meta({ + description: + 'One or more exact measurement timestamps to delete. Do not use together with `from-date` / `to-date` or `deleteAllMeasurements`.', + 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. Must be used by itself.', + example: 'true', + }), + }) + .meta({ + id: 'DeleteSensorMeasurementsQueryParams', + description: + 'Query parameters selecting which measurements should be deleted.', + }) + +const DeleteSensorMeasurementsResponseSchema = z + .object({ + message: z.string().meta({ + example: 'Successfully deleted 42 of sensor 60a13611a877b3001b8ffd59', + }), + }) + .meta({ + id: 'DeleteSensorMeasurementsResponse', + description: 'Response returned after deleting measurements from a sensor.', + }) + +const DeleteSensorMeasurementsBadRequestErrorSchema = + createBadRequestErrorSchema({ + id: 'DeleteSensorMeasurementsBadRequestError', + description: + 'Bad request. This can happen for invalid path parameters, invalid dates, invalid timestamp values, missing selection parameters, or mutually exclusive deletion parameters.', + examples: [ + 'Invalid device id or sensor id specified', + 'from-date is invalid', + 'to-date is invalid', + 'timestamps contains invalid input', + 'Parameter deleteAllMeasurements can only be used by itself', + 'Please specify only timestamps or a range with from-date and to-date', + ], + }) + +const parseQueryParams = async ( + request: Request, +): Promise> => { + const url = new URL(request.url) + const params: Record = Object.fromEntries(url.searchParams) + 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 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: { + description: 'Measurements deleted successfully.', + content: { + 'application/json': { + schema: DeleteSensorMeasurementsResponseSchema, + }, + }, + }, + + 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.', + ), + }, + }, +} + export async function action({ request, params }: Route.ActionArgs) { try { const { deviceId, sensorId } = params @@ -49,6 +200,7 @@ export async function action({ request, params }: Route.ActionArgs) { count = ( await deleteSensorMeasurementsForTimes( sensorId, + //@ts-ignore parsedParams.timestamps, ) ).count @@ -56,6 +208,7 @@ export async function action({ request, params }: Route.ActionArgs) { count = ( await deleteSensorMeasurementsForTimeRange( sensorId, + //@ts-ignore parsedParams['from-date'], parsedParams['to-date'], ) @@ -74,79 +227,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 9192ab2a..6f90f963 100644 --- a/app/routes/api.boxes.$deviceId.data.$sensorId.ts +++ b/app/routes/api.boxes.$deviceId.data.$sensorId.ts @@ -11,36 +11,15 @@ import { parseDateParam, parseEnumParam } from '~/lib/params' import { StandardResponse } from '~/lib/responses' import { z } from 'zod' import { type ZodOpenApiPathItemObject } from 'zod-openapi' - -const messages = { - invalidDeviceId: 'Invalid device id specified', - invalidSensorId: 'Invalid sensor id specified', - deviceNotFound: 'Device not found.', - internal: - 'The server was unable to complete your request. Please try again later.', -} - -const standardErrorResponseSchema = ( - code: Code, - messageSchema: z.ZodType = z.string(), -) => - z.object({ - code: z.literal(code), - message: messageSchema, - error: messageSchema, - }) - -const SensorDataPathParamsSchema = z.object({ - deviceId: z.string().min(1).meta({ - description: - 'The ID of the device you are referring to. This parameter is kept for legacy route compatibility.', - example: '5bdbe70f55d0ad001a04edc9', - }), - sensorId: z.string().min(1).meta({ - description: 'The ID of the sensor you are referring to', - example: '6649b23072c4c40007105953', - }), -}) +import { + badRequestResponse, + createBadRequestErrorSchema, + internalServerErrorResponse, + InternalServerErrorSchema, + NotFoundErrorSchema, + notFoundResponse, +} from '~/lib/openapi/errors' +import { DeviceSensorPathParamsSchema } from '~/lib/openapi/schemas/common' const SensorDataQueryParamsSchema = z.object({ outliers: z.enum(['replace', 'mark']).optional().meta({ @@ -48,34 +27,41 @@ const SensorDataQueryParamsSchema = z.object({ '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 moving window used as base to calculate the outliers.', + 'Size of the moving window used as base to calculate outliers. Allowed values are numbers between 1 and 50.', example: 15, }), - 'from-date': z.string().datetime().optional().meta({ + + 'from-date': z.iso.datetime().optional().meta({ description: 'Beginning date of measurement data. Defaults to 48 hours ago from now.', example: '2026-05-13T12:00:00.000Z', }), - 'to-date': z.string().datetime().optional().meta({ + + 'to-date': z.iso.datetime().optional().meta({ description: 'End date of measurement data. Defaults to now.', example: '2026-05-15T12:00:00.000Z', }), + format: z.enum(['json', 'csv']).default('json').meta({ description: "Response format. Can be 'json' or 'csv'. Defaults to 'json'.", example: 'json', }), + download: z.enum(['true', 'false']).optional().meta({ description: 'If set to `true`, the API sets a `Content-Disposition` header so browsers download the response instead of displaying it.', example: 'true', }), + delimiter: z.enum(['comma', 'semicolon']).default('comma').meta({ description: 'Only for CSV responses. Controls the CSV delimiter. Possible values are `comma` and `semicolon`. Defaults to `comma`. Do not use together with `separator`.', example: 'comma', }), + separator: z.enum(['comma', 'semicolon']).optional().meta({ description: 'Alias for `delimiter`. Only for CSV responses. Do not use together with `delimiter`.', @@ -89,19 +75,23 @@ const SensorMeasurementSchema = z description: 'ID of the sensor this measurement belongs to', example: '6649b23072c4c40007105953', }), - time: z.string().datetime().meta({ + + 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', }), + isOutlier: z.boolean().optional().meta({ description: 'Only present when outlier calculation is enabled via the `outliers` query parameter.', @@ -138,31 +128,22 @@ const SensorMeasurementsJsonResponseSchema = z const SensorMeasurementsCsvResponseSchema = z.string().meta({ id: 'SensorMeasurementsCsvResponse', description: - 'CSV response with one measurement per row. The delimiter is controlled by the `delimiter` query parameter.', + '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\n2023-09-29T08:06:11.513Z,6.38', + 'createdAt,value\n2023-09-29T08:06:13.254Z,6.38\n2023-09-29T08:06:12.312Z,6.38', }) -const BadRequestErrorSchema = standardErrorResponseSchema( - 'Bad Request', - z.string().meta({ - examples: [ - messages.invalidDeviceId, - messages.invalidSensorId, - 'Illegal value for parameter outlier-window. Allowed values: numbers between 1 and 50', - ], - }), -).meta({ id: 'BadRequestError' }) - -const NotFoundErrorSchema = standardErrorResponseSchema( - 'Not Found', - z.literal(messages.deviceNotFound), -).meta({ id: 'NotFoundError' }) - -const InternalServerErrorSchema = standardErrorResponseSchema( - 'Internal Server Error', - z.literal(messages.internal), -).meta({ id: 'InternalServerError' }) +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: { @@ -173,13 +154,13 @@ export const openapi: ZodOpenApiPathItemObject = { operationId: 'getSensorMeasurements', requestParams: { - path: SensorDataPathParamsSchema, + path: DeviceSensorPathParamsSchema, query: SensorDataQueryParamsSchema, }, responses: { 200: { - description: 'Success', + description: 'Measurements returned successfully.', headers: { 'Content-Disposition': { description: @@ -199,31 +180,18 @@ export const openapi: ZodOpenApiPathItemObject = { }, }, }, - 400: { - description: - 'Bad request. This can happen for invalid path parameters, invalid dates, invalid enum parameters, or an invalid outlier window.', - content: { - 'application/json': { - schema: BadRequestErrorSchema, - }, - }, - }, - 404: { - description: 'Device or sensor not found', - content: { - 'application/json': { - schema: NotFoundErrorSchema, - }, - }, - }, - 500: { - description: 'Internal server error', - content: { - 'application/json': { - schema: InternalServerErrorSchema, - }, - }, - }, + + 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.', + ), }, }, } diff --git a/app/routes/api.boxes.$deviceId.locations.ts b/app/routes/api.boxes.$deviceId.locations.ts index cc95c34b..6ec25b75 100644 --- a/app/routes/api.boxes.$deviceId.locations.ts +++ b/app/routes/api.boxes.$deviceId.locations.ts @@ -5,30 +5,20 @@ 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' + +import { + createBadRequestErrorSchema, + InternalServerErrorSchema, + NotFoundErrorSchema, +} from '~/lib/openapi/errors' + import { badRequestResponse, internalServerErrorResponse, notFoundResponse, -} from '~/lib/openapi/responses/errors' -import { - createBadRequestErrorSchema, - createInternalServerErrorSchema, - createNotFoundErrorSchema, -} from '~/lib/openapi/schemas/errors' - -const messages = { - invalidDeviceId: 'Invalid device id specified', - deviceNotFound: 'Device not found', - internal: - 'The server was unable to complete your request. Please try again later.', -} - -const DeviceLocationsPathParamsSchema = z.object({ - deviceId: z.string().min(1).meta({ - description: 'The ID of the device you are referring to', - example: '60a13611a877b3001b8ffd59', - }), -}) +} from '~/lib/openapi/errors' +import { apiMessages } from '~/lib/openapi/messages' +import { DevicePathParamsSchema } from '~/lib/openapi/schemas/common' const DeviceLocationsQueryParamsSchema = z.object({ 'from-date': z.string().datetime().optional().meta({ @@ -132,23 +122,15 @@ const GeoJsonLineStringResponseSchema = z }, }) -const BadRequestErrorSchema = createBadRequestErrorSchema({ +const DeviceLocationsBadRequestErrorSchema = createBadRequestErrorSchema({ id: 'DeviceLocationsBadRequestError', description: - 'Bad request. This can happen for an invalid device id, invalid date parameter, or invalid format parameter.', - examples: [messages.invalidDeviceId], -}) - -const NotFoundErrorSchema = createNotFoundErrorSchema({ - id: 'DeviceLocationsNotFoundError', - description: 'Returned when the requested device does not exist.', - messageSchema: z.literal(messages.deviceNotFound), -}) - -const InternalServerErrorSchema = createInternalServerErrorSchema({ - id: 'DeviceLocationsInternalServerError', - description: 'Returned when the server cannot complete the request.', - examples: [messages.internal], + '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 = { @@ -160,7 +142,7 @@ export const openapi: ZodOpenApiPathItemObject = { operationId: 'getDeviceLocations', requestParams: { - path: DeviceLocationsPathParamsSchema, + path: DevicePathParamsSchema, query: DeviceLocationsQueryParamsSchema, }, @@ -178,7 +160,7 @@ export const openapi: ZodOpenApiPathItemObject = { }, 400: badRequestResponse( - BadRequestErrorSchema, + DeviceLocationsBadRequestErrorSchema, 'Bad request. This can happen for an invalid device id, invalid date parameter, or invalid format parameter.', ), @@ -205,7 +187,7 @@ export const loader = async ({ const locations = await getLocations({ id: deviceId }, fromDate, toDate) if (!locations) { - return StandardResponse.notFound(messages.deviceNotFound) + return StandardResponse.notFound(apiMessages.deviceNotFound) } const jsonLocations = locations.map((location) => { @@ -262,7 +244,7 @@ function collectParameters( const deviceId = params.deviceId if (deviceId === undefined) { - return StandardResponse.badRequest(messages.invalidDeviceId) + return StandardResponse.badRequest(apiMessages.deviceIdRequired) } const url = new URL(request.url) diff --git a/app/routes/api.boxes.$deviceId.script.ts b/app/routes/api.boxes.$deviceId.script.ts index a69ea08e..62d2848b 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 efe24066..ecb95537 100644 --- a/app/routes/api.boxes.$deviceId.sensors.ts +++ b/app/routes/api.boxes.$deviceId.sensors.ts @@ -3,149 +3,32 @@ 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' const messages = { - invalidDeviceId: 'Invalid device id specified', invalidCount: 'Illegal value for parameter count. allowed values: numbers from 1 to 100', - deviceNotFound: 'Device not found', - internal: - 'The server was unable to complete your request. Please try again later.', } -const standardErrorResponseSchema = ( - code: Code, - messageSchema: z.ZodType = z.string(), -) => - z.object({ - code: z.literal(code), - message: messageSchema, - error: messageSchema, - }) - -const unknownJsonSchema = z.unknown().nullable().meta({ - description: 'Arbitrary JSON data', -}) - -const LastMeasurementSchema = z - .object({ - value: z.number().nullable().meta({ - example: 23.42, - }), - createdAt: z.string().datetime().meta({ - example: '2026-05-15T12:00:00.000Z', - }), - sensorId: z.string().meta({ - example: '60a13611a877b3001b8ffd59', - }), - }) - .meta({ - id: 'LastMeasurement', - description: 'Cached latest measurement for a sensor.', - }) - -const DeviceSensorsPathParamsSchema = z.object({ - deviceId: z.string().min(1).meta({ - description: 'The ID of the device you are referring to', - example: '60a13611a877b3001b8ffd59', - }), -}) - const DeviceSensorsQueryParamsSchema = z.object({ count: z.coerce.number().int().min(1).max(100).optional().meta({ - description: 'Number of measurements to retrieve for every sensor', + description: + 'Number of measurements to retrieve for every sensor. Allowed values are numbers from 1 to 100.', example: 5, }), }) -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.string().datetime().meta({ - description: 'Sensor creation timestamp', - example: '2026-05-15T12:00:00.000Z', - }), - updatedAt: z.string().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.', - }) - -const MeasurementSchema = z - .object({ - sensorId: z.string().meta({ - description: 'ID of the sensor this measurement belongs to', - example: '60a13611a877b3001b8ffd59', - }), - time: z.string().datetime().meta({ - description: 'Measurement timestamp', - example: '2026-05-15T12:00:00.000Z', - }), - value: z.number().nullable().meta({ - description: 'Measured value', - example: 23.42, - }), - /** - * Note: if this field is returned as a real JS bigint, - * Response.json will fail because BigInt cannot be JSON serialized. - * If it is converted before returning, document it as string or number. - */ - locationId: z.union([z.string(), z.number()]).nullable().meta({ - description: 'Location id associated with the measurement', - example: '123', - }), - }) - .meta({ - id: 'Measurement', - description: 'Measurement data.', - }) - const SensorWithLatestMeasurementSchema = SensorSchema.and( MeasurementSchema, ).meta({ @@ -154,11 +37,12 @@ const SensorWithLatestMeasurementSchema = SensorSchema.and( }) const DeviceWithSensorsSchema = z - .looseObject({ + .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.', @@ -166,77 +50,52 @@ const DeviceWithSensorsSchema = z }) .meta({ id: 'DeviceWithSensors', - description: - 'Device including sensors with their latest measurement data. Additional device fields are included according to the device model.', + description: 'Device including sensors with their latest measurement data.', }) -const BadRequestErrorSchema = standardErrorResponseSchema( - 'Bad Request', - z.union([ - z.literal(messages.invalidDeviceId), - z.literal(messages.invalidCount), - ]), -).meta({ id: 'BadRequestError' }) - -const NotFoundErrorSchema = standardErrorResponseSchema( - 'Not Found', - z.literal(messages.deviceNotFound), -).meta({ id: 'NotFoundError' }) - -const InternalServerErrorSchema = standardErrorResponseSchema( - 'Internal Server Error', - z.string().meta({ - example: messages.internal, - }), -).meta({ id: 'InternalServerError' }) +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 its latest measurement data. The optional `count` query parameter controls how many measurements are retrieved per sensor, depending on service behavior.', + '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: DeviceSensorsPathParamsSchema, + path: DevicePathParamsSchema, query: DeviceSensorsQueryParamsSchema, }, responses: { 200: { - description: 'Success', + description: 'Device sensors returned successfully.', content: { 'application/json': { schema: DeviceWithSensorsSchema, }, }, }, - 400: { - description: - 'Bad request. This can happen for an invalid device id or invalid count parameter.', - content: { - 'application/json': { - schema: BadRequestErrorSchema, - }, - }, - }, - 404: { - description: 'Device not found', - content: { - 'application/json': { - schema: NotFoundErrorSchema, - }, - }, - }, - 500: { - description: 'Internal server error', - content: { - 'application/json': { - schema: InternalServerErrorSchema, - }, - }, - }, + + 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.', + ), }, }, } @@ -249,7 +108,7 @@ export const loader = async ({ const deviceId = params.deviceId if (deviceId === undefined) { - return StandardResponse.badRequest(messages.invalidDeviceId) + return StandardResponse.badRequest(apiMessages.deviceIdRequired) } const url = new URL(request.url) @@ -268,7 +127,7 @@ export const loader = async ({ const meas = await getLatestMeasurements(deviceId, count) if (!meas) { - return StandardResponse.notFound(messages.deviceNotFound) + return StandardResponse.notFound(apiMessages.deviceNotFound) } return StandardResponse.ok(meas) diff --git a/app/routes/api.boxes.$deviceId.ts b/app/routes/api.boxes.$deviceId.ts index e90987c9..f49db6ea 100644 --- a/app/routes/api.boxes.$deviceId.ts +++ b/app/routes/api.boxes.$deviceId.ts @@ -12,132 +12,79 @@ 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' const messages = { - deviceIdRequired: 'Device ID is required.', - deviceNotFound: 'Device not found.', - invalidJwt: 'Invalid JWT authorization. Please sign in to obtain a new JWT.', - passwordRequired: 'Password is required for device deletion', - passwordIncorrect: 'Password incorrect', conflictingSensorsAndAddons: 'sensors and addons can not appear in the same request.', - internalFetching: 'Internal server error while fetching box', - internalDefault: - 'The server was unable to complete your request. Please try again later.', } -const standardErrorResponseSchema = ( - code: Code, - messageSchema: z.ZodType = z.string(), -) => - z.object({ - code: z.literal(code), - message: messageSchema, - error: messageSchema, - }) - -const DevicePathParamsSchema = z.object({ - deviceId: z.string().min(1).meta({ - description: 'Unique identifier of the device', - example: '5bdbe70f55d0ad001a04edc9', - }), -}) - -const LocationInputSchema = 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.', - }) - -const SensorUpdateSchema = z - .looseObject({ - 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: 'SensorUpdate', - description: 'Sensor update or creation payload.', - }) - -const DeviceAddonsSchema = 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.', - }) - const UpdateDeviceRequestSchema = z .object({ name: z.string().optional().meta({ description: 'Device name', example: 'My senseBox', }), + exposure: z.string().optional().meta({ description: 'Device exposure', example: 'outdoor', }), + description: z.string().optional().meta({ description: 'Device description', example: 'Sensor box on my balcony', }), + image: z.string().optional().meta({ description: 'Device image URL or image value', example: 'https://example.com/image.jpg', }), + deleteImage: z.boolean().optional().meta({ description: 'If true, the device image is removed by setting `image` to an empty string.', 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. This is mapped to `link` internally.', example: 'https://example.com', }), - location: LocationInputSchema.optional(), + + location: DeviceLocationInputSchema.optional(), + grouptag: z .array(z.string()) .optional() @@ -145,11 +92,13 @@ const UpdateDeviceRequestSchema = z description: 'Group tags assigned to the device', example: ['school', 'feinstaub'], }), - sensors: z.array(SensorUpdateSchema).optional().meta({ + + sensors: z.array(DeviceSensorUpdateSchema).optional().meta({ description: 'Sensors to update or create. Must not be used together with `addons.add`.', }), - addons: DeviceAddonsSchema.optional(), + + addons: DeviceAddonsUpdateSchema.optional(), }) .superRefine((body, ctx) => { if (body.sensors && body.addons?.add) { @@ -167,159 +116,33 @@ const UpdateDeviceRequestSchema = z const DeleteDeviceRequestSchema = z .object({ - password: z.string().min(1, messages.passwordRequired).meta({ - description: 'Current user password required to delete the device', - example: 'myCurrentPassword123', - format: 'password', - }), + 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 DeviceSchema = z - .looseObject({ - id: z.string().meta({ - description: 'Device id', - example: '5bdbe70f55d0ad001a04edc9', - }), - name: z.string().optional().meta({ - description: 'Device name', - example: 'My senseBox', - }), - exposure: z.string().optional().meta({ - description: 'Device exposure', - example: 'outdoor', - }), - description: z.string().nullable().optional().meta({ - description: 'Device description', - example: 'Sensor box on my balcony', - }), - model: z.string().nullable().optional().meta({ - description: 'Device model', - example: 'homeWifi', - }), - useAuth: z.boolean().optional().meta({ - description: 'Whether device API-key authentication is enabled', - example: true, - }), - sensors: z.array(z.looseObject({})).optional().meta({ - description: 'Sensors belonging to this device', - }), - createdAt: z.string().datetime().optional().meta({ - description: 'Device creation timestamp', - example: '2026-05-15T12:00:00.000Z', - }), - updatedAt: z.string().datetime().optional().meta({ - description: 'Device update timestamp', - example: '2026-05-15T12:00:00.000Z', - }), - }) - .meta({ - id: 'Device', - description: - 'Device object. Additional fields may be included depending on the database model.', - }) - -const ApiDeviceSchema = DeviceSchema.meta({ - id: 'ApiDevice', +const DeviceBadRequestErrorSchema = createBadRequestErrorSchema({ + id: 'DeviceBadRequestError', description: - 'Device object transformed to API format. Additional fields may be included depending on `transformDeviceToApiFormat`.', -}) - -const BadRequestErrorSchema = z - .union([ - standardErrorResponseSchema( - 'Bad Request', - z.union([ - z.literal(messages.deviceIdRequired), - z.literal(messages.passwordRequired), - ]), - ), - z.object({ - error: z.literal(messages.deviceIdRequired), - }), - z.object({ - code: z.literal('BadRequest'), - message: z.string().meta({ - example: messages.conflictingSensorsAndAddons, - }), - }), - ]) - .meta({ - id: 'DeviceBadRequestError', - description: - 'Bad request response. This route currently returns a few different bad-request shapes.', - }) - -const ForbiddenErrorSchema = z - .object({ - code: z.literal('Forbidden'), - message: z.literal(messages.invalidJwt), - }) - .meta({ - id: 'DeviceForbiddenError', - description: - 'Returned when the JWT authorization is invalid or missing for authenticated methods.', - }) - -const UnauthorizedErrorSchema = standardErrorResponseSchema( - 'Unauthorized', - z.literal(messages.passwordIncorrect), -).meta({ - id: 'DeviceUnauthorizedError', + '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, + ], }) -const NotFoundErrorSchema = z - .union([ - standardErrorResponseSchema( - 'Not Found', - z.literal(messages.deviceNotFound), - ), - z.object({ - code: z.literal('NotFound'), - message: z.literal('Device not found'), - }), - ]) - .meta({ - id: 'DeviceNotFoundError', - description: - 'Device not found response. GET/DELETE and PUT currently use slightly different shapes.', - }) - -const MethodNotAllowedErrorSchema = z - .object({ - message: z.literal('Method Not Allowed'), - }) - .meta({ - id: 'MethodNotAllowedError', - }) - -const InternalServerErrorSchema = z - .union([ - standardErrorResponseSchema( - 'Internal Server Error', - z.string().meta({ - example: messages.internalDefault, - }), - ), - z.object({ - error: z.literal(messages.internalFetching), - }), - z.object({ - code: z.literal('InternalServerError'), - message: z.string().meta({ - example: 'Failed to update device', - }), - }), - ]) - .meta({ - id: 'DeviceInternalServerError', - description: - 'Internal server error response. This route currently returns different error shapes depending on the failing method.', - }) - export const openapi: ZodOpenApiPathItemObject = { get: { tags: ['Boxes'], @@ -333,37 +156,25 @@ export const openapi: ZodOpenApiPathItemObject = { responses: { 200: { - description: 'Device retrieved successfully', + description: 'Device retrieved successfully.', content: { 'application/json': { - schema: DeviceSchema, - }, - }, - }, - 400: { - description: 'Device ID is required', - content: { - 'application/json': { - schema: BadRequestErrorSchema, - }, - }, - }, - 404: { - description: 'Device not found', - content: { - 'application/json': { - schema: NotFoundErrorSchema, - }, - }, - }, - 500: { - description: 'Internal server error', - content: { - 'application/json': { - schema: InternalServerErrorSchema, + 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.', + ), }, }, @@ -390,46 +201,30 @@ export const openapi: ZodOpenApiPathItemObject = { responses: { 200: { - description: 'Device updated successfully', + description: 'Device updated successfully.', content: { 'application/json': { schema: ApiDeviceSchema, }, }, }, - 400: { - description: - 'Bad request. This can happen for conflicting parameters or validation errors.', - content: { - 'application/json': { - schema: BadRequestErrorSchema, - }, - }, - }, - 403: { - description: 'Invalid or missing JWT authorization', - content: { - 'application/json': { - schema: ForbiddenErrorSchema, - }, - }, - }, - 404: { - description: 'Device not found', - content: { - 'application/json': { - schema: NotFoundErrorSchema, - }, - }, - }, - 500: { - description: 'Internal server error', - content: { - 'application/json': { - schema: InternalServerErrorSchema, - }, - }, - }, + + 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.', + ), }, }, @@ -456,7 +251,7 @@ export const openapi: ZodOpenApiPathItemObject = { responses: { 200: { - description: 'Device deleted successfully', + description: 'Device deleted successfully.', content: { 'application/json': { schema: z.null().meta({ @@ -465,46 +260,28 @@ export const openapi: ZodOpenApiPathItemObject = { }, }, }, - 400: { - description: 'Bad request - missing device id or password', - content: { - 'application/json': { - schema: BadRequestErrorSchema, - }, - }, - }, - 401: { - description: 'Unauthorized - incorrect password', - content: { - 'application/json': { - schema: UnauthorizedErrorSchema, - }, - }, - }, - 403: { - description: 'Invalid or missing JWT authorization', - content: { - 'application/json': { - schema: ForbiddenErrorSchema, - }, - }, - }, - 404: { - description: 'Device not found', - content: { - 'application/json': { - schema: NotFoundErrorSchema, - }, - }, - }, - 500: { - description: 'Internal server error', - content: { - 'application/json': { - schema: InternalServerErrorSchema, - }, - }, - }, + + 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.', + ), }, }, } diff --git a/app/routes/api.boxes.claim.ts b/app/routes/api.boxes.claim.ts index 78d8a44e..b73136c2 100644 --- a/app/routes/api.boxes.claim.ts +++ b/app/routes/api.boxes.claim.ts @@ -3,16 +3,166 @@ 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 senseBox 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', ) - if (request.method !== 'POST') - return StandardResponse.methodNotAllowed('Only POST allowed') - const jwtResponse = await getUserFromJwt(request) if (typeof jwtResponse === 'string') @@ -28,7 +178,9 @@ export const action = async ({ request }: Route.ActionArgs) => { return StandardResponse.ok({ message: 'Device successfully claimed!', - data: result, + data: { + boxId: result.boxId, + }, }) } catch (err) { console.error('Error claiming box:', err) diff --git a/app/routes/api.boxes.transfer.$deviceId.ts b/app/routes/api.boxes.transfer.$deviceId.ts index 064266ac..648cb8ee 100644 --- a/app/routes/api.boxes.transfer.$deviceId.ts +++ b/app/routes/api.boxes.transfer.$deviceId.ts @@ -6,6 +6,184 @@ import { 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 senseBox.', + }) + +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 senseBox.', + }) + +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 senseBox', + description: + 'Returns transfer information for a senseBox. 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) diff --git a/app/routes/api.boxes.transfer.ts b/app/routes/api.boxes.transfer.ts index 42527217..d03b1cca 100644 --- a/app/routes/api.boxes.transfer.ts +++ b/app/routes/api.boxes.transfer.ts @@ -7,6 +7,208 @@ import { 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 senseBox 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', + }), + + date: z.iso.datetime().optional().meta({ + description: + 'Legacy alias for `expiresAt`. Kept for backwards compatibility.', + example: '2026-05-22T12:00:00.000Z', + }), + }) + .meta({ + id: 'CreateBoxTransferRequest', + description: 'Payload for marking a senseBox for transfer.', + }) + +const RemoveBoxTransferRequestSchema = z + .object({ + boxId: z.string().min(1).meta({ + description: 'ID of the senseBox to remove from transfer.', + example: '5bdbe70f55d0ad001a04edc9', + }), + + token: TransferTokenSchema, + }) + .meta({ + id: 'RemoveBoxTransferRequest', + description: 'Payload for revoking a senseBox transfer token.', + }) + +const CreateBoxTransferResponseSchema = z + .object({ + code: z.literal('Created').default('Created'), + message: z + .literal('Box successfully prepared for transfer') + .default('Box 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 senseBox for transfer', + description: + 'Marks a senseBox 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. `date` is supported as a legacy alias for `expiresAt`.', + 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 senseBox transfer token', + description: + 'Revokes a transfer token and removes the senseBox 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) diff --git a/app/routes/api.boxes.ts b/app/routes/api.boxes.ts index 67e78c07..c7795053 100644 --- a/app/routes/api.boxes.ts +++ b/app/routes/api.boxes.ts @@ -15,256 +15,47 @@ import { import { z } from 'zod' import { type ZodOpenApiPathItemObject } from 'zod-openapi' - -const messages = { - invalidJwt: 'Invalid JWT authorization. Please sign in to obtain new JWT.', - invalidJson: 'Invalid JSON in request body', - invalidRequestData: 'Invalid request data', - invalidFormat: 'Invalid format parameter', - methodNotAllowed: 'Method Not Allowed', - internal: - 'The server was unable to complete your request. Please try again later.', -} - -const standardErrorResponseSchema = ( - code: Code, - messageSchema: z.ZodType = z.string(), -) => - z.object({ - code: z.literal(code), - message: messageSchema, - error: messageSchema, - }) +import { + ApiDeviceSchema, + DevicesGeoJsonResponseSchema, + DevicesResponseSchema, +} from '~/lib/openapi/schemas/device' +import { + BadRequestErrorSchema, + badRequestResponse, + createBadRequestErrorSchema, + ForbiddenErrorSchema, + forbiddenResponse, + internalServerErrorResponse, + InternalServerErrorSchema, + MethodNotAllowedErrorSchema, + methodNotAllowedResponse, + UnprocessableContentErrorSchema, + unprocessableContentResponse, +} from '~/lib/openapi/errors' +import { apiMessages } from '~/lib/openapi/messages' const BoxesQueryParamsSchema = BoxesQuerySchema.meta({ id: 'BoxesQueryParams', - description: 'Query parameters used to filter and format the boxes response.', + description: + 'Query parameters used to filter and format the devices response.', }) const CreateBoxRequestSchema = CreateBoxSchema.meta({ id: 'CreateBoxRequest', - description: 'Payload for creating a new box/device.', -}) - -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.', - }) - -const SensorSchema = 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.string().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: 'BoxSensor', - description: 'Sensor belonging to a box/device.', - }) - -const BoxSchema = z - .looseObject({ - _id: z.string().optional().meta({ - description: 'Unique box identifier in API format', - example: 'clx1234567890abcdef', - }), - id: z.string().optional().meta({ - description: 'Unique device identifier', - example: 'clx1234567890abcdef', - }), - name: z.string().meta({ - description: 'Box name', - example: 'My Weather Station', - }), - description: z.string().nullable().optional().meta({ - description: 'Box description', - example: 'A weather monitoring station', - }), - image: z.string().nullable().optional().meta({ - description: 'Box image URL', - example: 'https://example.com/image.jpg', - }), - link: z.string().nullable().optional().meta({ - description: 'Box website link', - example: 'https://example.com', - }), - grouptag: z - .array(z.string()) - .optional() - .meta({ - description: 'Box group tags', - example: ['weather', 'outdoor'], - }), - exposure: z.string().nullable().optional().meta({ - description: 'Box exposure type', - example: 'outdoor', - }), - model: z.string().nullable().optional().meta({ - description: 'Box model', - example: 'homeV2Wifi', - }), - latitude: z.number().nullable().optional().meta({ - description: 'Box latitude', - example: 52.520008, - }), - longitude: z.number().nullable().optional().meta({ - description: 'Box longitude', - example: 13.404954, - }), - useAuth: z.boolean().optional().meta({ - description: 'Whether box requires authentication', - example: true, - }), - public: z.boolean().optional().meta({ - description: 'Whether box is public', - example: false, - }), - status: z.string().nullable().optional().meta({ - description: 'Box status', - example: 'inactive', - }), - createdAt: z.string().datetime().optional().meta({ - description: 'Box creation timestamp', - example: '2024-01-15T10:30:00.000Z', - }), - updatedAt: z.string().datetime().optional().meta({ - description: 'Box last update timestamp', - example: '2024-01-15T10:30:00.000Z', - }), - expiresAt: z.string().datetime().nullable().optional().meta({ - description: 'Box 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.string().datetime().optional(), - }) - .optional() - .meta({ - description: 'Current location as GeoJSON Point-like object', - }), - lastMeasurementAt: z.string().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: 'Box integrations', - example: { - mqtt: { - enabled: false, - }, - }, - }), - sensors: z.array(SensorSchema).optional().meta({ - description: 'Sensors belonging to this box', - }), - }) - .meta({ - id: 'Box', - description: - 'Box/device object. The exact shape depends on whether the response is returned directly from the database or transformed through `transformDeviceToApiFormat`.', - }) - -const BoxesResponseSchema = z.array(BoxSchema).meta({ - id: 'BoxesResponse', - description: 'List of boxes/devices.', + description: 'Payload for creating a new device.', }) -const BoxesGeoJsonResponseSchema = z - .object({ - type: z.literal('FeatureCollection'), - features: z.array( - z.object({ - type: z.literal('Feature'), - geometry: GeoJsonPointSchema, - properties: BoxSchema, - }), - ), - }) - .meta({ - id: 'BoxesGeoJsonResponse', - description: - 'GeoJSON FeatureCollection of boxes. 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', - }, - }, - ], - }, - }) - -const CreatedBoxResponseSchema = BoxSchema.meta({ +const CreatedBoxResponseSchema = ApiDeviceSchema.meta({ id: 'CreatedBoxResponse', description: 'Created box/device response transformed through `transformDeviceToApiFormat`.', }) -const ValidationBadRequestErrorSchema = z +const CreateBoxValidationErrorSchema = z .object({ code: z.literal('Bad Request'), - message: z.literal(messages.invalidRequestData), + message: z.literal(apiMessages.invalidRequestData), errors: z.array(z.string()).meta({ description: 'Validation errors returned by CreateBoxSchema', example: [ @@ -279,10 +70,14 @@ const ValidationBadRequestErrorSchema = z 'Validation error response for invalid create-box request payloads.', }) -const BadRequestErrorSchema = z +const BoxesBadRequestErrorSchema = z .union([ - standardErrorResponseSchema('Bad Request', z.literal(messages.invalidJson)), - ValidationBadRequestErrorSchema, + createBadRequestErrorSchema({ + id: 'BoxesInvalidJsonBadRequestError', + description: 'Returned when the request body is not valid JSON.', + messageSchema: z.literal(apiMessages.invalidJson), + }), + CreateBoxValidationErrorSchema, ]) .meta({ id: 'BoxesBadRequestError', @@ -290,42 +85,12 @@ const BadRequestErrorSchema = z 'Bad request response. Invalid JSON uses the standard error shape; validation errors include an `errors` array.', }) -const ForbiddenErrorSchema = standardErrorResponseSchema( - 'Forbidden', - z.literal(messages.invalidJwt), -).meta({ - id: 'ForbiddenError', -}) - -const MethodNotAllowedErrorSchema = standardErrorResponseSchema( - 'Method Not Allowed', - z.literal(messages.methodNotAllowed), -).meta({ - id: 'MethodNotAllowedError', -}) - -const UnprocessableContentErrorSchema = standardErrorResponseSchema( - 'Unprocessable Content', - z.string().meta({ - example: messages.invalidFormat, - }), -).meta({ - id: 'UnprocessableContentError', -}) - -const InternalServerErrorSchema = standardErrorResponseSchema( - 'Internal Server Error', - z.literal(messages.internal), -).meta({ - id: 'InternalServerError', -}) - export const openapi: ZodOpenApiPathItemObject = { get: { tags: ['Boxes'], - summary: 'Get boxes', + summary: 'Get devices', description: - 'Find boxes/devices using query parameters. By default, a JSON array of boxes is returned. If `format=geojson`, a GeoJSON FeatureCollection is returned.', + '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: { @@ -334,29 +99,26 @@ export const openapi: ZodOpenApiPathItemObject = { responses: { 200: { - description: 'Boxes retrieved successfully', + description: 'Devices retrieved successfully.', content: { 'application/json': { - schema: z.union([BoxesResponseSchema, BoxesGeoJsonResponseSchema]), + schema: DevicesResponseSchema, }, - }, - }, - 422: { - description: 'Invalid query parameter', - content: { - 'application/json': { - schema: UnprocessableContentErrorSchema, - }, - }, - }, - 500: { - description: 'Internal server error', - content: { - 'application/json': { - schema: InternalServerErrorSchema, + 'application/geo+json': { + schema: DevicesGeoJsonResponseSchema, }, }, }, + + 422: unprocessableContentResponse( + UnprocessableContentErrorSchema, + 'Invalid query parameter.', + ), + + 500: internalServerErrorResponse( + InternalServerErrorSchema, + 'Internal server error.', + ), }, }, @@ -377,58 +139,34 @@ export const openapi: ZodOpenApiPathItemObject = { }, responses: { - 200: { - description: 'Boxes retrieved successfully', - content: { - 'application/json': { - schema: BoxesResponseSchema, - }, - 'application/geo+json': { - schema: BoxesGeoJsonResponseSchema, - }, - }, - }, 201: { - description: 'Box created successfully', + description: 'Box created successfully.', content: { 'application/json': { schema: CreatedBoxResponseSchema, }, }, }, - 400: { - description: - 'Bad request. This can happen when the request body is not valid JSON or does not match CreateBoxSchema.', - content: { - 'application/json': { - schema: BadRequestErrorSchema, - }, - }, - }, - 403: { - description: 'Forbidden - invalid or missing JWT token', - content: { - 'application/json': { - schema: ForbiddenErrorSchema, - }, - }, - }, - 405: { - description: 'Method not allowed - only POST is supported for actions', - content: { - 'application/json': { - schema: MethodNotAllowedErrorSchema, - }, - }, - }, - 500: { - description: 'Internal server error', - content: { - 'application/json': { - schema: InternalServerErrorSchema, - }, - }, - }, + + 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.', + ), }, }, } diff --git a/app/routes/api.users.me.boxes.$deviceId.ts b/app/routes/api.users.me.boxes.$deviceId.ts index 411843f2..09e753bb 100644 --- a/app/routes/api.users.me.boxes.$deviceId.ts +++ b/app/routes/api.users.me.boxes.$deviceId.ts @@ -3,6 +3,84 @@ 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 CurrentUserPrivateBoxSchema = ApiDeviceSchema.meta({ + id: 'CurrentUserPrivateBox', + description: + 'Box owned by the authenticated user. This response may include private or secret fields.', +}) + +const GetCurrentUserBoxResponseSchema = z + .object({ + code: z.literal('Ok').default('Ok'), + data: z.object({ + box: CurrentUserPrivateBoxSchema, + }), + }) + .meta({ + id: 'GetCurrentUserBoxResponse', + description: 'Response containing one box owned by the authenticated user.', + }) + +export const openapi: ZodOpenApiPathItemObject = { + get: { + tags: ['User Management'], + summary: 'Get one box of the current user', + description: + 'Returns a specific box owned by the authenticated user. This endpoint may include private or secret fields that are not returned by public box endpoints.', + operationId: 'getCurrentUserBox', + security: [{ bearerAuth: [] }], + + requestParams: { + path: DevicePathParamsSchema, + }, + + responses: { + 200: { + description: 'Box returned successfully.', + content: { + 'application/json': { + schema: GetCurrentUserBoxResponseSchema, + }, + }, + }, + + 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 senseBox.', + ), + + 500: internalServerErrorResponse( + InternalServerErrorSchema, + 'Internal server error.', + ), + }, + }, +} + export const loader = async ({ request, params }: Route.LoaderArgs) => { try { const jwtResponse = await getUserFromJwt(request) diff --git a/app/routes/api.users.me.resend-email-confirmation.ts b/app/routes/api.users.me.resend-email-confirmation.ts index 9f460f96..4d3d9c66 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 546fb8dd..60f76137 100644 --- a/app/routes/api.users.me.ts +++ b/app/routes/api.users.me.ts @@ -6,74 +6,32 @@ import { getUserFromJwt } 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' const messages = { - invalidJwt: 'Invalid JWT authorization. Please sign in to obtain new JWT.', - internal: - 'The server was unable to complete your request. Please try again later.', noChanges: 'No changed properties supplied. User remains unchanged.', badRequest: 'Bad Request', - passwordIncorrect: 'Password incorrect', currentPasswordRequired: 'Current password is required when setting a new password', -} - -/** - * During migration I would keep this loose, because jwtResponse / updatedUser - * may contain additional fields that your service layer still needs. - * - * Once you know the exact public response shape, you can switch this to - * z.object(...) for stricter output. - */ -const UserSchema = z - .looseObject({ - id: z.string().meta({ - description: 'Unique user identifier', - example: 'user_123456', - }), - email: z.string().email().meta({ - description: "User's email address", - example: 'user@example.com', - }), - name: z.string().meta({ - description: "User's display name", - example: 'John Doe', - }), - language: z.string().meta({ - description: "User's preferred language", - example: 'en', - }), - role: z.string().optional().meta({ - description: "User's role", - example: 'user', - }), - emailIsConfirmed: z.boolean().optional().meta({ - description: "Whether the user's email address is confirmed", - example: true, - }), - createdAt: z.string().datetime().optional().meta({ - description: 'Account creation timestamp', - example: '2024-01-15T10:30:00Z', - }), - updatedAt: z.string().datetime().optional().meta({ - description: 'Last account update timestamp', - example: '2024-01-20T14:45:00Z', - }), - }) - .meta({ - id: 'User', - description: 'User profile information', - }) - -const UserWithBoxesSchema = UserSchema.extend({ - boxes: z.array(z.string()).meta({ - description: 'A list of ids of the users devices', - example: ['60a13611a877b3001b8ffd59', '5bdbe70f55d0ad001a04edc9'], - }), -}).meta({ - id: 'UserWithBoxes', - description: 'User profile information including device ids', -}) + passwordIncorrect: 'Password incorrect', +} as const const GetMeResponseSchema = z .object({ @@ -82,24 +40,24 @@ const GetMeResponseSchema = z me: UserWithBoxesSchema, }), }) - .meta({ id: 'GetCurrentUserResponse' }) + .meta({ + id: 'GetCurrentUserResponse', + description: 'Current authenticated user including device ids.', + }) -const PutRequestSchema = z +const UpdateCurrentUserRequestSchema = z .object({ email: z.string().trim().email().optional().meta({ description: 'New email address', example: 'newemail@example.com', }), - language: z.string().trim().min(1).optional().meta({ - description: 'Preferred language setting', - example: 'en', - }), + language: UserLanguageSchema.optional(), name: z.string().trim().min(1).optional().meta({ description: "User's display name", example: 'John Doe', }), currentPassword: z.string().min(1).optional().meta({ - description: 'Current password, required for password changes', + description: 'Current password, required when setting a new password', example: 'currentPassword123', format: 'password', }), @@ -118,9 +76,12 @@ const PutRequestSchema = z }) } }) - .meta({ id: 'UpdateCurrentUserRequest' }) + .meta({ + id: 'UpdateCurrentUserRequest', + description: 'Payload for updating the authenticated user profile.', + }) -const PutUpdatedResponseSchema = z +const UpdateCurrentUserSuccessResponseSchema = z .object({ code: z.literal('Ok').default('Ok'), message: z.string().meta({ @@ -132,25 +93,31 @@ const PutUpdatedResponseSchema = z }) .meta({ id: 'UpdateCurrentUserSuccessResponse', - description: 'Profile updated successfully', + description: 'Profile updated successfully.', }) -const PutNoChangesResponseSchema = z +const UpdateCurrentUserNoChangesResponseSchema = z .object({ code: z.literal('Ok').default('Ok'), message: z.literal(messages.noChanges).default(messages.noChanges), }) .meta({ id: 'UpdateCurrentUserNoChangesResponse', - description: 'No changes made', + description: 'No profile changes were applied.', }) -const PutResponseSchema = z.union([ - PutUpdatedResponseSchema, - PutNoChangesResponseSchema, -]) +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 DeleteRequestSchema = z +const DeleteCurrentUserRequestSchema = z .object({ password: z.string().min(1, messages.badRequest).meta({ description: 'Current password for account deletion confirmation', @@ -158,160 +125,146 @@ const DeleteRequestSchema = z format: 'password', }), }) - .meta({ id: 'DeleteCurrentUserRequest' }) - -const ForbiddenErrorSchema = z - .object({ - code: z.literal('Forbidden').default('Forbidden'), - message: z.literal(messages.invalidJwt).default(messages.invalidJwt), - error: z.literal(messages.invalidJwt).optional(), - }) - .meta({ id: 'ForbiddenError' }) - -const BadRequestErrorSchema = z - .object({ - code: z.literal('Bad Request').default('Bad Request'), - message: z.string().meta({ - example: 'Current password is incorrect', - }), - error: z.string().optional(), - }) - .meta({ id: 'BadRequestError' }) - -const UnauthorizedErrorSchema = z - .object({ - code: z.literal('Unauthorized').default('Unauthorized'), - message: z.literal(messages.passwordIncorrect), - error: z.literal(messages.passwordIncorrect).optional(), - }) - .meta({ id: 'UnauthorizedError' }) - -const InternalServerErrorSchema = z - .object({ - code: z.literal('Internal Server Error').default('Internal Server Error'), - message: z.literal(messages.internal).default(messages.internal), - error: z.literal(messages.internal).optional(), + .meta({ + id: 'DeleteCurrentUserRequest', + description: 'Payload for deleting the authenticated user account.', }) - .meta({ id: 'InternalServerError' }) export const openapi: ZodOpenApiPathItemObject = { get: { tags: ['User Management'], summary: 'Get current user profile', - description: "Retrieves the authenticated user's profile information", + 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: { - description: 'Invalid or missing JWT token', + description: 'Successfully retrieved user profile.', content: { - 'application/json': { schema: ForbiddenErrorSchema }, - }, - }, - 500: { - description: 'Internal server error', - content: { - 'application/json': { schema: InternalServerErrorSchema }, + 'application/json': { + schema: GetMeResponseSchema, + }, }, }, + + 403: forbiddenResponse( + ForbiddenErrorSchema, + 'Invalid or missing JWT authorization.', + ), + + 500: internalServerErrorResponse( + InternalServerErrorSchema, + 'Internal server error.', + ), }, }, put: { tags: ['User Management'], - summary: 'Update user profile', - description: "Updates the authenticated user's profile information", - operationId: 'updateUserProfile', + 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: PutRequestSchema }, + 'application/json': { + schema: UpdateCurrentUserRequestSchema, + }, }, }, + responses: { 200: { - description: 'User profile updated successfully or no changes made', - content: { - 'application/json': { schema: PutResponseSchema }, - }, - }, - 400: { - description: 'Bad request - validation errors', - content: { - 'application/json': { schema: BadRequestErrorSchema }, - }, - }, - 403: { - description: 'Invalid or missing JWT token', + description: + 'User profile updated successfully, or no changed properties were supplied.', content: { - 'application/json': { schema: ForbiddenErrorSchema }, - }, - }, - 500: { - description: 'Internal server error', - content: { - 'application/json': { schema: InternalServerErrorSchema }, + '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 user account', - description: "Permanently deletes the authenticated user's account", - operationId: 'deleteUserAccount', + 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: DeleteRequestSchema, + schema: DeleteCurrentUserRequestSchema, }, }, }, + responses: { 200: { - description: 'Account successfully deleted', + description: 'Account successfully deleted.', content: { 'application/json': { schema: z.null().meta({ - description: 'Empty response indicating successful deletion', + description: + 'JSON null response indicating successful account deletion.', }), }, }, }, - 400: { - description: 'Bad request - missing password', - content: { - 'application/json': { schema: BadRequestErrorSchema }, - }, - }, - 401: { - description: 'Unauthorized - incorrect password', - content: { - 'application/json': { schema: UnauthorizedErrorSchema }, - }, - }, - 403: { - description: 'Invalid or missing JWT token', - content: { - 'application/json': { schema: ForbiddenErrorSchema }, - }, - }, - 500: { - description: 'Internal server error', - content: { - 'application/json': { schema: InternalServerErrorSchema }, - }, - }, + + 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.', + ), }, }, } @@ -331,7 +284,7 @@ export const loader = async ({ request }: Route.LoaderArgs) => { const jwtResponse = await getUserFromJwt(request) if (typeof jwtResponse === 'string') { - return StandardResponse.forbidden(messages.invalidJwt) + return StandardResponse.forbidden(apiMessages.invalidJwt) } const deviceIds = await getUserDeviceIds(jwtResponse.id) @@ -382,7 +335,8 @@ const put = async (user: User, request: Request): Promise => { return StandardResponse.badRequest(messages.badRequest) } - const requestParsed = await PutRequestSchema.safeParseAsync(body) + const requestParsed = + await UpdateCurrentUserRequestSchema.safeParseAsync(body) if (!requestParsed.success) { return StandardResponse.badRequest( @@ -393,7 +347,7 @@ const put = async (user: User, request: Request): Promise => { const jwtString = getBearerToken(request) if (!jwtString) { - return StandardResponse.forbidden(messages.invalidJwt) + return StandardResponse.forbidden(apiMessages.invalidJwt) } const { @@ -409,10 +363,11 @@ const put = async (user: User, request: Request): Promise => { return StandardResponse.badRequest(messageText) } - const responseParsed = await PutNoChangesResponseSchema.safeParseAsync({ - code: 'Ok', - message: messages.noChanges, - }) + const responseParsed = + await UpdateCurrentUserNoChangesResponseSchema.safeParseAsync({ + code: 'Ok', + message: messages.noChanges, + }) if (!responseParsed.success) { console.warn(responseParsed.error) @@ -422,11 +377,12 @@ const put = async (user: User, request: Request): Promise => { return StandardResponse.ok(responseParsed.data) } - const responseParsed = await PutUpdatedResponseSchema.safeParseAsync({ - code: 'Ok', - message: `User successfully saved. ${messageText}`, - data: { me: updatedUser }, - }) + const responseParsed = + await UpdateCurrentUserSuccessResponseSchema.safeParseAsync({ + code: 'Ok', + message: `User successfully saved. ${messageText}`, + data: { me: updatedUser }, + }) if (!responseParsed.success) { console.warn(responseParsed.error) @@ -450,7 +406,7 @@ const del = async (user: User, request: Request): Promise => { return StandardResponse.badRequest(messages.badRequest) } - const requestParsed = DeleteRequestSchema.safeParse( + const requestParsed = DeleteCurrentUserRequestSchema.safeParse( Object.fromEntries(formData.entries()), ) @@ -463,7 +419,7 @@ const del = async (user: User, request: Request): Promise => { const jwtString = getBearerToken(request) if (!jwtString) { - return StandardResponse.forbidden(messages.invalidJwt) + return StandardResponse.forbidden(apiMessages.invalidJwt) } const deleted = await deleteUser( @@ -473,7 +429,7 @@ const del = async (user: User, request: Request): Promise => { ) if (deleted === 'unauthorized') { - return StandardResponse.unauthorized(messages.passwordIncorrect) + return StandardResponse.unauthorized(apiMessages.passwordIncorrect) } return StandardResponse.ok(null) diff --git a/app/routes/api.users.password-reset.ts b/app/routes/api.users.password-reset.ts index d2486349..ec46a7e5 100644 --- a/app/routes/api.users.password-reset.ts +++ b/app/routes/api.users.password-reset.ts @@ -2,6 +2,125 @@ import { type Route } from './+types/api.users.password-reset' import { StandardResponse } from '~/lib/responses' import { resetPassword } from '~/services/user-service.server' +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().min(8).meta({ + description: 'New password. Must be at least 8 characters long.', + example: 'newPassword456', + format: 'password', + }), + + token: z.string().min(1).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.', + ), + }, + }, +} + export const action = async ({ request }: Route.ActionArgs) => { let formData = new FormData() try { @@ -40,7 +159,7 @@ export const action = async ({ request }: Route.ActionArgs) => { ) 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({ diff --git a/app/routes/api.users.refresh-auth.ts b/app/routes/api.users.refresh-auth.ts index 953e6e01..afd562bf 100644 --- a/app/routes/api.users.refresh-auth.ts +++ b/app/routes/api.users.refresh-auth.ts @@ -5,76 +5,44 @@ 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' 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.', - internal: - 'The server was unable to complete your request. Please try again later.', } -const standardErrorResponseSchema = ( - code: Code, - messageSchema: z.ZodType = z.string(), -) => - z.object({ - code: z.literal(code), - message: messageSchema, - error: messageSchema, - }) - const RefreshAuthRequestSchema = z .object({ - token: z.string().trim().min(1, errorMessages.tokenRequired).meta({ - description: 'Valid refresh token', - example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', - }), + 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.', + description: + 'Refresh authentication request body. Can be submitted as JSON or form data.', }) -const UserSchema = z - .looseObject({ - id: z.string().meta({ - description: 'Unique user identifier', - example: 'user_123456', - }), - email: z.string().email().meta({ - description: "User's email address", - example: 'user@example.com', - }), - name: z.string().meta({ - description: "User's display name", - example: 'John Doe', - }), - language: z.string().optional().meta({ - description: "User's preferred language", - example: 'en', - }), - role: z.string().optional().meta({ - description: "User's role", - example: 'user', - }), - emailIsConfirmed: z.boolean().optional().meta({ - description: "Whether the user's email address is confirmed", - example: true, - }), - createdAt: z.string().datetime().optional().meta({ - description: 'Account creation timestamp', - example: '2024-01-15T10:30:00.000Z', - }), - updatedAt: z.string().datetime().optional().meta({ - description: 'Last account update timestamp', - example: '2024-01-20T14:45:00.000Z', - }), - }) - .meta({ - id: 'User', - description: 'User information object.', - }) +const JwtTokenSchema = z.jwt({ alg: 'HS256' }).meta({ + description: 'JWT access token', + example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', +}) const RefreshAuthResponseSchema = z .object({ @@ -85,9 +53,8 @@ const RefreshAuthResponseSchema = z data: z.object({ user: UserSchema, }), - token: z.jwt({ alg: 'HS256' }).meta({ + token: JwtTokenSchema.meta({ description: 'New JWT access token', - example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', }), refreshToken: z.string().meta({ description: 'New refresh token', @@ -99,9 +66,11 @@ const RefreshAuthResponseSchema = z description: 'Successfully refreshed authentication response.', }) -const ForbiddenErrorSchema = standardErrorResponseSchema( - 'Forbidden', - z.union([ +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({ @@ -109,19 +78,6 @@ const ForbiddenErrorSchema = standardErrorResponseSchema( 'Invalid request format: Failed to parse request body as JSON or form data', }), ]), -).meta({ - id: 'RefreshAuthForbiddenError', - description: - 'Authentication failed because the refresh token is missing, invalid, expired, or the request body could not be parsed.', -}) - -const InternalServerErrorSchema = standardErrorResponseSchema( - 'Internal Server Error', - z.string().meta({ - example: errorMessages.internal, - }), -).meta({ - id: 'InternalServerError', }) export const openapi: ZodOpenApiPathItemObject = { @@ -129,9 +85,10 @@ export const openapi: ZodOpenApiPathItemObject = { tags: ['Authentication'], summary: 'Refresh authentication token', description: - 'Refreshes a JWT access token using a valid refresh token. The current access token must be supplied via the Authorization header, and the refresh token must be supplied in the request body.', + '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: { @@ -143,32 +100,26 @@ export const openapi: ZodOpenApiPathItemObject = { }, }, }, + responses: { 200: { - description: 'Successfully refreshed authentication', + description: 'Successfully refreshed authentication.', content: { 'application/json': { schema: RefreshAuthResponseSchema, }, }, }, - 403: { - description: - 'Authentication failed - missing, invalid, expired, or malformed refresh token request', - content: { - 'application/json': { - schema: ForbiddenErrorSchema, - }, - }, - }, - 500: { - description: 'Internal server error', - content: { - 'application/json': { - schema: InternalServerErrorSchema, - }, - }, - }, + + 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.', + ), }, }, } diff --git a/app/routes/api.users.register.ts b/app/routes/api.users.register.ts index 6218f908..4a2fb1e5 100644 --- a/app/routes/api.users.register.ts +++ b/app/routes/api.users.register.ts @@ -4,6 +4,201 @@ 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 'zod-openapi' +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' + +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.default('en_US').optional().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': diff --git a/app/routes/api.users.request-password-reset.ts b/app/routes/api.users.request-password-reset.ts index e821873a..0b2616dd 100644 --- a/app/routes/api.users.request-password-reset.ts +++ b/app/routes/api.users.request-password-reset.ts @@ -2,6 +2,88 @@ import { type Route } from './+types/api.users.request-password-reset' import { StandardResponse } from '~/lib/responses' import { requestPasswordReset } from '~/services/user-service.server' +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' + +const RequestPasswordResetRequestSchema = z + .object({ + email: z.string().trim().pipe(z.email()).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.', + ), + }, + }, +} + export const action = async ({ request }: Route.ActionArgs) => { let formData = new FormData() try { @@ -12,11 +94,19 @@ export const action = async ({ request }: Route.ActionArgs) => { // request was sent without x-www-form-urlencoded content-type header } - if ( - !formData.has('email') || - formData.get('email')?.toString().trim().length === 0 - ) + 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.') + } + + await requestPasswordReset(parsedEmail.data) try { await requestPasswordReset(formData.get('email')!.toString()) From 6df030e2ec25883e442e7b256306b36575772aad Mon Sep 17 00:00:00 2001 From: jona159 Date: Tue, 26 May 2026 14:34:33 +0200 Subject: [PATCH 14/29] fix: deprecated --- app/lib/openapi/schemas/common.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/app/lib/openapi/schemas/common.ts b/app/lib/openapi/schemas/common.ts index d8178d6c..f9dcfff1 100644 --- a/app/lib/openapi/schemas/common.ts +++ b/app/lib/openapi/schemas/common.ts @@ -16,14 +16,11 @@ export const SensorIdSchema = z.string().min(1).meta({ }) export const DeviceSensorPathParamsSchema = z.object({ - deviceId: DeviceIdSchema.meta({ - description: - 'Unique identifier of the device. This parameter is kept for legacy route compatibility.', - }), + deviceId: DeviceIdSchema, sensorId: SensorIdSchema, }) -export const IsoDateTimeSchema = z.string().datetime().meta({ +export const IsoDateTimeSchema = z.iso.datetime().meta({ description: 'ISO 8601 timestamp', example: '2026-05-18T12:34:56.000Z', }) From 3b7535eac4bc3de8b4e264d283e3034ffc16d14f Mon Sep 17 00:00:00 2001 From: jona159 Date: Tue, 26 May 2026 14:34:47 +0200 Subject: [PATCH 15/29] feat: add generic message response --- app/lib/openapi/errors/responses.ts | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/app/lib/openapi/errors/responses.ts b/app/lib/openapi/errors/responses.ts index 91009170..7893bd43 100644 --- a/app/lib/openapi/errors/responses.ts +++ b/app/lib/openapi/errors/responses.ts @@ -1,6 +1,16 @@ -import type { ZodType } from 'zod/v4' +import { z, type ZodType } from 'zod/v4' -export const jsonErrorResponse = (description: string, schema: ZodType) => ({ +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': { @@ -9,6 +19,13 @@ export const jsonErrorResponse = (description: string, schema: ZodType) => ({ }, }) +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.', From 78064b9e62e6a065a8ec0abbbcfdb16472d67d38 Mon Sep 17 00:00:00 2001 From: jona159 Date: Tue, 26 May 2026 14:45:02 +0200 Subject: [PATCH 16/29] fix: simplify schemas, bugix for deleteAllMeasurements param --- ....boxes.$deviceId.$sensorId.measurements.ts | 77 +++++++++---------- 1 file changed, 38 insertions(+), 39 deletions(-) diff --git a/app/routes/api.boxes.$deviceId.$sensorId.measurements.ts b/app/routes/api.boxes.$deviceId.$sensorId.measurements.ts index 85fda3cc..352c069a 100644 --- a/app/routes/api.boxes.$deviceId.$sensorId.measurements.ts +++ b/app/routes/api.boxes.$deviceId.$sensorId.measurements.ts @@ -12,7 +12,10 @@ import * as z from 'zod/v4' import 'zod-openapi' import { type ZodOpenApiPathItemObject } from 'zod-openapi' -import { DeviceSensorPathParamsSchema } from '~/lib/openapi/schemas/common' +import { + DeviceSensorPathParamsSchema, + IsoDateTimeSchema, +} from '~/lib/openapi/schemas/common' import { ForbiddenErrorSchema, @@ -20,6 +23,7 @@ import { MethodNotAllowedErrorSchema, NotFoundErrorSchema, createBadRequestErrorSchema, + messageResponse, } from '~/lib/openapi/errors' import { @@ -30,32 +34,36 @@ import { notFoundResponse, } from '~/lib/openapi/errors' +const IsoDateTimeToDateSchema = IsoDateTimeSchema.transform( + (value) => new Date(value), +) + const DeleteSensorMeasurementsQueryParamsSchema = z .object({ - 'from-date': z.iso.datetime().optional().meta({ - description: - 'Beginning date of the measurement range to delete. Must be used together with `to-date`.', + 'from-date': IsoDateTimeToDateSchema.optional().meta({ + description: 'Beginning date of the measurement range to delete.', example: '2026-05-13T12:00:00.000Z', }), - 'to-date': z.iso.datetime().optional().meta({ - description: - 'End date of the measurement range to delete. Must be used together with `from-date`.', + 'to-date': IsoDateTimeToDateSchema.optional().meta({ + description: 'End date of the measurement range to delete.', example: '2026-05-15T12:00:00.000Z', }), timestamps: z - .union([z.iso.datetime(), z.array(z.iso.datetime())]) + .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. Do not use together with `from-date` / `to-date` or `deleteAllMeasurements`.', + 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. Must be used by itself.', + description: 'Set to `true` to delete all measurements of this sensor.', example: 'true', }), }) @@ -64,30 +72,16 @@ const DeleteSensorMeasurementsQueryParamsSchema = z description: 'Query parameters selecting which measurements should be deleted.', }) - -const DeleteSensorMeasurementsResponseSchema = z - .object({ - message: z.string().meta({ - example: 'Successfully deleted 42 of sensor 60a13611a877b3001b8ffd59', - }), - }) - .meta({ - id: 'DeleteSensorMeasurementsResponse', - description: 'Response returned after deleting measurements from a sensor.', - }) - const DeleteSensorMeasurementsBadRequestErrorSchema = createBadRequestErrorSchema({ id: 'DeleteSensorMeasurementsBadRequestError', description: - 'Bad request. This can happen for invalid path parameters, invalid dates, invalid timestamp values, missing selection parameters, or mutually exclusive deletion parameters.', + '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', - 'Parameter deleteAllMeasurements can only be used by itself', - 'Please specify only timestamps or a range with from-date and to-date', ], }) @@ -95,7 +89,21 @@ const parseQueryParams = async ( request: Request, ): Promise> => { const url = new URL(request.url) - const params: Record = Object.fromEntries(url.searchParams) + 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) @@ -123,14 +131,7 @@ export const openapi: ZodOpenApiPathItemObject = { }, responses: { - 200: { - description: 'Measurements deleted successfully.', - content: { - 'application/json': { - schema: DeleteSensorMeasurementsResponseSchema, - }, - }, - }, + 200: messageResponse('Measurements deleted successfully.'), 400: badRequestResponse( DeleteSensorMeasurementsBadRequestErrorSchema, @@ -194,13 +195,12 @@ 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 = ( await deleteSensorMeasurementsForTimes( sensorId, - //@ts-ignore parsedParams.timestamps, ) ).count @@ -208,7 +208,6 @@ export async function action({ request, params }: Route.ActionArgs) { count = ( await deleteSensorMeasurementsForTimeRange( sensorId, - //@ts-ignore parsedParams['from-date'], parsedParams['to-date'], ) From 8ac6537d760942fd3cdabd685461f26cda63ceaa Mon Sep 17 00:00:00 2001 From: jona159 Date: Tue, 26 May 2026 14:45:20 +0200 Subject: [PATCH 17/29] fix: date params as iso strings, add test case for deleteAllMeasurements --- ...es.$deviceId.$sensorId.measurement.spec.ts | 36 +++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/tests/routes/api.boxes.$deviceId.$sensorId.measurement.spec.ts b/tests/routes/api.boxes.$deviceId.$sensorId.measurement.spec.ts index 7e992ef9..abe586da 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 () => { From d9304dbf7fa8dd79ea4051d2f62cea06ba02c8d5 Mon Sep 17 00:00:00 2001 From: jona159 Date: Tue, 26 May 2026 14:58:53 +0200 Subject: [PATCH 18/29] feat: reuse iso date time schema --- app/routes/api.boxes.$deviceId.data.$sensorId.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/app/routes/api.boxes.$deviceId.data.$sensorId.ts b/app/routes/api.boxes.$deviceId.data.$sensorId.ts index 6f90f963..17d932e3 100644 --- a/app/routes/api.boxes.$deviceId.data.$sensorId.ts +++ b/app/routes/api.boxes.$deviceId.data.$sensorId.ts @@ -19,7 +19,10 @@ import { NotFoundErrorSchema, notFoundResponse, } from '~/lib/openapi/errors' -import { DeviceSensorPathParamsSchema } from '~/lib/openapi/schemas/common' +import { + DeviceSensorPathParamsSchema, + IsoDateTimeSchema, +} from '~/lib/openapi/schemas/common' const SensorDataQueryParamsSchema = z.object({ outliers: z.enum(['replace', 'mark']).optional().meta({ @@ -34,13 +37,13 @@ const SensorDataQueryParamsSchema = z.object({ example: 15, }), - 'from-date': z.iso.datetime().optional().meta({ + 'from-date': IsoDateTimeSchema.optional().meta({ description: 'Beginning date of measurement data. Defaults to 48 hours ago from now.', example: '2026-05-13T12:00:00.000Z', }), - 'to-date': z.iso.datetime().optional().meta({ + 'to-date': IsoDateTimeSchema.optional().meta({ description: 'End date of measurement data. Defaults to now.', example: '2026-05-15T12:00:00.000Z', }), From e27f6142e2e65b168ff21e50b9e42aeced6fa8e1 Mon Sep 17 00:00:00 2001 From: jona159 Date: Tue, 26 May 2026 15:30:38 +0200 Subject: [PATCH 19/29] fix: rm unused schemas, unify response type --- app/routes/api.boxes.ts | 43 +++++------------------------------------ 1 file changed, 5 insertions(+), 38 deletions(-) diff --git a/app/routes/api.boxes.ts b/app/routes/api.boxes.ts index c7795053..98ec573b 100644 --- a/app/routes/api.boxes.ts +++ b/app/routes/api.boxes.ts @@ -13,7 +13,6 @@ import { CreateBoxSchema, } from '~/services/device-service.server' -import { z } from 'zod' import { type ZodOpenApiPathItemObject } from 'zod-openapi' import { ApiDeviceSchema, @@ -23,7 +22,6 @@ import { import { BadRequestErrorSchema, badRequestResponse, - createBadRequestErrorSchema, ForbiddenErrorSchema, forbiddenResponse, internalServerErrorResponse, @@ -33,7 +31,6 @@ import { UnprocessableContentErrorSchema, unprocessableContentResponse, } from '~/lib/openapi/errors' -import { apiMessages } from '~/lib/openapi/messages' const BoxesQueryParamsSchema = BoxesQuerySchema.meta({ id: 'BoxesQueryParams', @@ -52,39 +49,6 @@ const CreatedBoxResponseSchema = ApiDeviceSchema.meta({ 'Created box/device response transformed through `transformDeviceToApiFormat`.', }) -const CreateBoxValidationErrorSchema = z - .object({ - code: z.literal('Bad Request'), - message: z.literal(apiMessages.invalidRequestData), - errors: z.array(z.string()).meta({ - description: 'Validation errors returned by CreateBoxSchema', - example: [ - 'name: Required', - 'location: Expected array, received undefined', - ], - }), - }) - .meta({ - id: 'CreateBoxValidationError', - description: - 'Validation error response for invalid create-box request payloads.', - }) - -const BoxesBadRequestErrorSchema = z - .union([ - createBadRequestErrorSchema({ - id: 'BoxesInvalidJsonBadRequestError', - description: 'Returned when the request body is not valid JSON.', - messageSchema: z.literal(apiMessages.invalidJson), - }), - CreateBoxValidationErrorSchema, - ]) - .meta({ - id: 'BoxesBadRequestError', - description: - 'Bad request response. Invalid JSON uses the standard error shape; validation errors include an `errors` array.', - }) - export const openapi: ZodOpenApiPathItemObject = { get: { tags: ['Boxes'], @@ -210,9 +174,12 @@ export async function loader({ request }: Route.LoaderArgs) { 'Content-Type': 'application/geo+json; charset=utf-8', }, }) - } else { - return devices } + return Response.json(devices, { + headers: { + 'Content-Type': 'application/json; charset=utf-8', + }, + }) } export const action = async ({ request }: Route.ActionArgs) => { From c01837125373da4098ca9c8cfbd9bcea1aba5d32 Mon Sep 17 00:00:00 2001 From: jona159 Date: Tue, 26 May 2026 15:35:24 +0200 Subject: [PATCH 20/29] fix(tests): call json method on response, rm duplicate test --- tests/routes/api.boxes.spec.ts | 247 ++++++++++++++++----------------- 1 file changed, 120 insertions(+), 127 deletions(-) diff --git a/tests/routes/api.boxes.spec.ts b/tests/routes/api.boxes.spec.ts index 816048cc..c4123827 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', () => { From bd4409f84a1bf452862eab8720b6da2fb9077a10 Mon Sep 17 00:00:00 2001 From: jona159 Date: Tue, 26 May 2026 15:55:27 +0200 Subject: [PATCH 21/29] fix: format --- app/routes/api.users.sign-in.ts | 261 +++++++++++++++++--------------- 1 file changed, 138 insertions(+), 123 deletions(-) diff --git a/app/routes/api.users.sign-in.ts b/app/routes/api.users.sign-in.ts index e1cd1a23..785c7756 100644 --- a/app/routes/api.users.sign-in.ts +++ b/app/routes/api.users.sign-in.ts @@ -1,139 +1,154 @@ -import { z } from "zod"; -import { type Route } from "./+types/api.users.sign-in"; -import { StandardResponse } from "~/lib/responses"; -import { signIn } from "~/services/user-service.server"; -import { type ZodOpenApiPathItemObject } from "zod-openapi"; +import { z } from 'zod' +import { type Route } from './+types/api.users.sign-in' +import { StandardResponse } from '~/lib/responses' +import { signIn } from '~/services/user-service.server' +import { type ZodOpenApiPathItemObject } from 'zod-openapi' import { - requestContentTypeJson, - responseContentTypeJson, -} from "~/middleware/content-type-header.server"; + 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!", -}; + 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", - }), -}); + 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 PostResponseSchema = z.object({ - data: z.object( - { - user: z.object({ - name: z.string(), - ...PostRequestSchema.pick({ email: true }).shape, - role: z.string(), - language: z.string(), - emailIsConfirmed: z.boolean(), - boxes: z.array(z.string()).meta({ - description: "A list of ids of the users devices", - example: ["60a13611a877b3001b8ffd59", "5bdbe70f55d0ad001a04edc9"], - }), - }), - }, - errorMessages.userAndOrPassword, - ), - token: z.jwt({ alg: "HS256", error: errorMessages.userAndOrPassword }).meta({ - description: "valid json web token", - }), - refreshToken: z.string(errorMessages.userAndOrPassword).meta({ - description: "valid json web token", - }), - code: z.literal("Authorized").default("Authorized"), - message: z.literal("Successfully signed in").default("Successfully signed in"), -}); + 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'), +}) export const openapi: ZodOpenApiPathItemObject = { - post: { - tags: ["Auth"], - summary: "Sign in using email or name and password", - requestBody: { - required: true, - content: { - "application/json": { schema: PostRequestSchema }, - }, - }, - responses: { - 200: { - description: "Signed in", - content: { - "application/json": { schema: PostResponseSchema }, - }, - }, - 403: { - description: "Unauthorized", - content: { - "application/json": { - schema: z.object({ - code: z.literal("Forbidden"), - message: z.xor([ - z.literal(errorMessages.email), - z.literal(errorMessages.password), - z.literal(errorMessages.userAndOrPassword), - ]), - error: z.xor([ - z.literal(errorMessages.email), - z.literal(errorMessages.password), - z.literal(errorMessages.userAndOrPassword), - ]), - }), - }, - }, - }, - 500: { - description: "Internal Server Error", - content: { - "application/json": { - schema: z.object({ - code: z.literal("Internal Server Error"), - message: z.literal( - "The server was unable to complete your request. Please try again later.", - ), - error: z.literal( - "The server was unable to complete your request. Please try again later.", - ), - }), - }, - }, - }, - }, - }, -}; + 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.', + ), + }), + }, + }, + }, + }, + }, +} export const middleware: Route.MiddlewareFunction[] = [ - requestContentTypeJson, - responseContentTypeJson, -]; + 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); + 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)) || {}; + const { email, password } = requestParsed.data + const { user, jwt, refreshToken } = (await signIn(email, password)) || {} - const responseParsed = await PostResponseSchema.safeParseAsync({ - data: { user }, - token: jwt, - refreshToken, - }); - if (!responseParsed.success) - return StandardResponse.forbidden(responseParsed.error.issues[0].message); + const responseParsed = await PostResponseSchema.safeParseAsync({ + data: { user }, + token: jwt, + refreshToken, + }) + if (!responseParsed.success) + return StandardResponse.forbidden(responseParsed.error.issues[0].message) - return StandardResponse.ok(responseParsed.data); - } catch (error) { - console.warn(error); - return StandardResponse.internalServerError(); - } -}; + return StandardResponse.ok(responseParsed.data) + } catch (error) { + console.warn(error) + return StandardResponse.internalServerError() + } +} From 548c8665c12bc6c4c9e43aaf54aa00cf4df21d27 Mon Sep 17 00:00:00 2001 From: jona159 Date: Tue, 26 May 2026 15:55:49 +0200 Subject: [PATCH 22/29] fix: add helper, fix content-type --- tests/routes/api.users.sign-in.spec.ts | 73 ++++++++++---------------- 1 file changed, 29 insertions(+), 44 deletions(-) diff --git a/tests/routes/api.users.sign-in.spec.ts b/tests/routes/api.users.sign-in.spec.ts index e41f4cea..31220562 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', } +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({ From 1a1b96b2f9c4256636da885d8e2c713c9697e62a Mon Sep 17 00:00:00 2001 From: jona159 Date: Tue, 26 May 2026 16:27:12 +0200 Subject: [PATCH 23/29] fix: create test user with valid tld email --- tests/data/generate_test_user.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/data/generate_test_user.ts b/tests/data/generate_test_user.ts index 6b49b79f..44ad420b 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'), } } From eba145560c4051ae6bd1d302a9568dc7b43565ae Mon Sep 17 00:00:00 2001 From: jona159 Date: Tue, 26 May 2026 16:27:54 +0200 Subject: [PATCH 24/29] fix: duplicate function call --- app/routes/api.users.request-password-reset.ts | 4 +--- .../routes/api.users.request-password-reset.spec.ts | 13 ++++++++++--- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/app/routes/api.users.request-password-reset.ts b/app/routes/api.users.request-password-reset.ts index 0b2616dd..c03bd68f 100644 --- a/app/routes/api.users.request-password-reset.ts +++ b/app/routes/api.users.request-password-reset.ts @@ -106,10 +106,8 @@ export const action = async ({ request }: Route.ActionArgs) => { return StandardResponse.badRequest('Invalid email address.') } - await requestPasswordReset(parsedEmail.data) - try { - await requestPasswordReset(formData.get('email')!.toString()) + 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 diff --git a/tests/routes/api.users.request-password-reset.spec.ts b/tests/routes/api.users.request-password-reset.spec.ts index aca9f032..39077fc6 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', + }) }) }) From 7ee9c2cc2e776e2bccd299b6be391300cbe918c5 Mon Sep 17 00:00:00 2001 From: jona159 Date: Tue, 26 May 2026 17:09:03 +0200 Subject: [PATCH 25/29] fix: transform user to api format --- app/lib/user-transform.ts | 17 +++++++ app/routes/api.users.me.ts | 50 +++++++++++++++------ tests/routes/api.users.refresh-auth.spec.ts | 25 ++++------- tests/routes/api.users.sign-in.spec.ts | 2 +- 4 files changed, 63 insertions(+), 31 deletions(-) create mode 100644 app/lib/user-transform.ts diff --git a/app/lib/user-transform.ts b/app/lib/user-transform.ts new file mode 100644 index 00000000..a4b71761 --- /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/routes/api.users.me.ts b/app/routes/api.users.me.ts index 60f76137..caca8512 100644 --- a/app/routes/api.users.me.ts +++ b/app/routes/api.users.me.ts @@ -24,6 +24,7 @@ import { unauthorizedResponse, } from '~/lib/openapi/errors' import { apiMessages } from '~/lib/openapi/messages' +import { transformUserToApiFormat } from '~/lib/user-transform' const messages = { noChanges: 'No changed properties supplied. User remains unchanged.', @@ -52,22 +53,38 @@ const UpdateCurrentUserRequestSchema = z example: 'newemail@example.com', }), language: UserLanguageSchema.optional(), - name: z.string().trim().min(1).optional().meta({ - description: "User's display name", - example: 'John Doe', - }), + 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).optional().meta({ - description: 'New password', - example: 'newPassword456', - 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', @@ -291,11 +308,16 @@ export const loader = async ({ request }: Route.LoaderArgs) => { const responseParsed = await GetMeResponseSchema.safeParseAsync({ code: 'Ok', - data: { me: { ...jwtResponse, boxes: deviceIds } }, + data: { + me: { + ...transformUserToApiFormat(jwtResponse), + boxes: deviceIds, + }, + }, }) if (!responseParsed.success) { - console.warn(responseParsed.error) + console.error(responseParsed.error.issues) return StandardResponse.internalServerError() } @@ -358,8 +380,8 @@ const put = async (user: User, request: Request): Promise => { const messageText = updateMessages.join('.') - if (updated === false) { - if (updateMessages.length > 0) { + if (updated === false || updateMessages.length === 0) { + if (updated === false && updateMessages.length > 0) { return StandardResponse.badRequest(messageText) } @@ -381,7 +403,7 @@ const put = async (user: User, request: Request): Promise => { await UpdateCurrentUserSuccessResponseSchema.safeParseAsync({ code: 'Ok', message: `User successfully saved. ${messageText}`, - data: { me: updatedUser }, + data: { me: transformUserToApiFormat(updatedUser) }, }) if (!responseParsed.success) { diff --git a/tests/routes/api.users.refresh-auth.spec.ts b/tests/routes/api.users.refresh-auth.spec.ts index 74479a94..e0439749 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.sign-in.spec.ts b/tests/routes/api.users.sign-in.spec.ts index 31220562..5f7408ba 100644 --- a/tests/routes/api.users.sign-in.spec.ts +++ b/tests/routes/api.users.sign-in.spec.ts @@ -10,7 +10,7 @@ const VALID_SIGN_IN_TEST_USER = { password: 'some secure password', } -const createSignInRequest = (email: string, password: string) => +export const createSignInRequest = (email: string, password: string) => new Request(`${BASE_URL}/users/sign-in`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, From 4da5481f49c536216efbbd1f54d5c59e6018bbb7 Mon Sep 17 00:00:00 2001 From: jona159 Date: Wed, 27 May 2026 08:38:37 +0200 Subject: [PATCH 26/29] fix: wording --- app/routes/api.boxes.transfer.ts | 26 ++++++++++---------------- 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/app/routes/api.boxes.transfer.ts b/app/routes/api.boxes.transfer.ts index d03b1cca..e1911bc9 100644 --- a/app/routes/api.boxes.transfer.ts +++ b/app/routes/api.boxes.transfer.ts @@ -35,7 +35,7 @@ const TransferTokenSchema = z.string().min(1).meta({ const CreateBoxTransferRequestSchema = z .object({ boxId: z.string().min(1).meta({ - description: 'ID of the senseBox to mark for transfer.', + description: 'ID of the device to mark for transfer.', example: '5bdbe70f55d0ad001a04edc9', }), @@ -44,22 +44,16 @@ const CreateBoxTransferRequestSchema = z 'Expiration date for the transfer token. If omitted, the default is 24 hours from now.', example: '2026-05-22T12:00:00.000Z', }), - - date: z.iso.datetime().optional().meta({ - description: - 'Legacy alias for `expiresAt`. Kept for backwards compatibility.', - example: '2026-05-22T12:00:00.000Z', - }), }) .meta({ id: 'CreateBoxTransferRequest', - description: 'Payload for marking a senseBox for transfer.', + description: 'Payload for marking a device for transfer.', }) const RemoveBoxTransferRequestSchema = z .object({ boxId: z.string().min(1).meta({ - description: 'ID of the senseBox to remove from transfer.', + description: 'ID of the device to remove from transfer.', example: '5bdbe70f55d0ad001a04edc9', }), @@ -67,15 +61,15 @@ const RemoveBoxTransferRequestSchema = z }) .meta({ id: 'RemoveBoxTransferRequest', - description: 'Payload for revoking a senseBox transfer token.', + description: 'Payload for revoking a device transfer token.', }) const CreateBoxTransferResponseSchema = z .object({ code: z.literal('Created').default('Created'), message: z - .literal('Box successfully prepared for transfer') - .default('Box successfully prepared for transfer'), + .literal('Device successfully prepared for transfer') + .default('Device successfully prepared for transfer'), data: TransferTokenSchema.meta({ description: 'Generated transfer token.', }), @@ -101,9 +95,9 @@ const BoxTransferBadRequestErrorSchema = createBadRequestErrorSchema({ export const openapi: ZodOpenApiPathItemObject = { post: { tags: ['Boxes'], - summary: 'Mark a senseBox for transfer', + summary: 'Mark a device for transfer', description: - 'Marks a senseBox 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. `date` is supported as a legacy alias for `expiresAt`.', + '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. `date` is supported as a legacy alias for `expiresAt`.', operationId: 'createBoxTransfer', security: [{ bearerAuth: [] }], @@ -158,9 +152,9 @@ export const openapi: ZodOpenApiPathItemObject = { delete: { tags: ['Boxes'], - summary: 'Revoke a senseBox transfer token', + summary: 'Revoke a device transfer token', description: - 'Revokes a transfer token and removes the senseBox from transfer. Requires JWT authorization. The request body can be sent as JSON or form data.', + '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: [] }], From 956fb1d1d58c06949d9d298165fa2f23a272ccee Mon Sep 17 00:00:00 2001 From: jona159 Date: Wed, 27 May 2026 13:53:41 +0200 Subject: [PATCH 27/29] feat: migrate to zod-openapi --- app/routes/api.boxes.$deviceId.data.ts | 261 +++++++++++++++++++++++-- 1 file changed, 246 insertions(+), 15 deletions(-) diff --git a/app/routes/api.boxes.$deviceId.data.ts b/app/routes/api.boxes.$deviceId.data.ts index 371ad200..4c822cd2 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, From 9124ea9dd966db03839b06a2165146914d58fc18 Mon Sep 17 00:00:00 2001 From: jona159 Date: Wed, 27 May 2026 13:54:26 +0200 Subject: [PATCH 28/29] fix: wording, add schemas, fix deprecation errors --- app/lib/openapi/errors/schemas.ts | 7 +++ app/lib/openapi/schemas/location.ts | 56 +++++++++++++++++++++ app/routes/api.boxes.$deviceId.locations.ts | 19 ++++--- app/routes/api.boxes.$deviceId.ts | 14 ++---- app/routes/api.boxes.claim.ts | 2 +- app/routes/api.boxes.transfer.$deviceId.ts | 8 +-- app/routes/api.boxes.transfer.ts | 2 +- app/routes/api.users.me.boxes.$deviceId.ts | 27 +++++----- app/routes/api.users.me.ts | 2 +- 9 files changed, 98 insertions(+), 39 deletions(-) create mode 100644 app/lib/openapi/schemas/location.ts diff --git a/app/lib/openapi/errors/schemas.ts b/app/lib/openapi/errors/schemas.ts index 69d7ed00..9bff3a5f 100644 --- a/app/lib/openapi/errors/schemas.ts +++ b/app/lib/openapi/errors/schemas.ts @@ -18,6 +18,13 @@ export const BadRequestErrorSchema = standardErrorResponseSchema( 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.' }), diff --git a/app/lib/openapi/schemas/location.ts b/app/lib/openapi/schemas/location.ts new file mode 100644 index 00000000..d0c232da --- /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/routes/api.boxes.$deviceId.locations.ts b/app/routes/api.boxes.$deviceId.locations.ts index 6ec25b75..97625391 100644 --- a/app/routes/api.boxes.$deviceId.locations.ts +++ b/app/routes/api.boxes.$deviceId.locations.ts @@ -18,15 +18,19 @@ import { notFoundResponse, } from '~/lib/openapi/errors' import { apiMessages } from '~/lib/openapi/messages' -import { DevicePathParamsSchema } from '~/lib/openapi/schemas/common' +import { + DevicePathParamsSchema, + IsoDateTimeSchema, +} from '~/lib/openapi/schemas/common' +import { CoordinatesSchema } from '~/lib/openapi/schemas/location' const DeviceLocationsQueryParamsSchema = z.object({ - 'from-date': z.string().datetime().optional().meta({ + 'from-date': IsoDateTimeSchema.optional().meta({ description: 'Beginning date of location data. Defaults to 48 hours ago from now.', example: '2026-05-13T12:00:00.000Z', }), - 'to-date': z.string().datetime().optional().meta({ + 'to-date': IsoDateTimeSchema.optional().meta({ description: 'End date of location data. Defaults to now.', example: '2026-05-15T12:00:00.000Z', }), @@ -36,16 +40,11 @@ const DeviceLocationsQueryParamsSchema = z.object({ }), }) -const CoordinatesSchema = z.tuple([z.number(), z.number()]).meta({ - description: '[longitude, latitude]', - example: [7.68123, 51.9123], -}) - const PointLocationSchema = z .object({ coordinates: CoordinatesSchema, type: z.literal('Point'), - timestamp: z.string().datetime().meta({ + timestamp: IsoDateTimeSchema.meta({ description: 'Timestamp of the device location', example: '2017-07-27T12:00:00.000Z', }), @@ -92,7 +91,7 @@ const GeoJsonLineStringResponseSchema = z }), properties: z.object({ timestamps: z.array( - z.string().datetime().meta({ + IsoDateTimeSchema.meta({ example: '2017-07-27T12:00:00.000Z', }), ), diff --git a/app/routes/api.boxes.$deviceId.ts b/app/routes/api.boxes.$deviceId.ts index f49db6ea..bd0eb279 100644 --- a/app/routes/api.boxes.$deviceId.ts +++ b/app/routes/api.boxes.$deviceId.ts @@ -43,7 +43,7 @@ const UpdateDeviceRequestSchema = z .object({ name: z.string().optional().meta({ description: 'Device name', - example: 'My senseBox', + example: 'My device', }), exposure: z.string().optional().meta({ @@ -53,17 +53,15 @@ const UpdateDeviceRequestSchema = z description: z.string().optional().meta({ description: 'Device description', - example: 'Sensor box on my balcony', + example: 'Sensor device on my balcony', }), image: z.string().optional().meta({ description: 'Device image URL or image value', - example: 'https://example.com/image.jpg', }), deleteImage: z.boolean().optional().meta({ - description: - 'If true, the device image is removed by setting `image` to an empty string.', + description: 'If true, the device image is removed.', example: true, }), @@ -78,8 +76,7 @@ const UpdateDeviceRequestSchema = z }), weblink: z.string().optional().meta({ - description: - 'Web link for the device. This is mapped to `link` internally.', + description: 'Web link for the device.', example: 'https://example.com', }), @@ -181,8 +178,7 @@ export const openapi: ZodOpenApiPathItemObject = { put: { tags: ['Boxes'], summary: 'Update device', - description: - 'Updates a device. Requires JWT authorization. Supports legacy addon behavior, image deletion, location updates, group tag updates, and sensor updates.', + description: 'Updates a device. Requires JWT authorization.', operationId: 'updateDevice', security: [{ bearerAuth: [] }], diff --git a/app/routes/api.boxes.claim.ts b/app/routes/api.boxes.claim.ts index b73136c2..9762254e 100644 --- a/app/routes/api.boxes.claim.ts +++ b/app/routes/api.boxes.claim.ts @@ -95,7 +95,7 @@ export const openapi: ZodOpenApiPathItemObject = { tags: ['Boxes'], summary: 'Claim a transferred device', description: - 'Claims a senseBox that has been marked for transfer. Requires a valid JWT bearer token and a valid transfer token in the JSON request body.', + '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: [] }], diff --git a/app/routes/api.boxes.transfer.$deviceId.ts b/app/routes/api.boxes.transfer.$deviceId.ts index 648cb8ee..ea02b944 100644 --- a/app/routes/api.boxes.transfer.$deviceId.ts +++ b/app/routes/api.boxes.transfer.$deviceId.ts @@ -52,7 +52,7 @@ const GetBoxTransferResponseSchema = z }) .meta({ id: 'GetBoxTransferResponse', - description: 'Transfer information for a senseBox.', + description: 'Transfer information for a device.', }) const UpdateBoxTransferResponseSchema = z @@ -65,7 +65,7 @@ const UpdateBoxTransferResponseSchema = z }) .meta({ id: 'UpdateBoxTransferResponse', - description: 'Updated transfer information for a senseBox.', + description: 'Updated transfer information for a device.', }) const BoxTransferByDeviceBadRequestErrorSchema = createBadRequestErrorSchema({ @@ -86,9 +86,9 @@ const BoxTransferByDeviceBadRequestErrorSchema = createBadRequestErrorSchema({ export const openapi: ZodOpenApiPathItemObject = { get: { tags: ['Boxes'], - summary: 'Get transfer information for a senseBox', + summary: 'Get transfer information for a device', description: - 'Returns transfer information for a senseBox. Requires JWT authorization. Only the owner of the box can view its transfer information.', + 'Returns transfer information for a device. Requires JWT authorization. Only the owner of the box can view its transfer information.', operationId: 'getBoxTransfer', security: [{ bearerAuth: [] }], diff --git a/app/routes/api.boxes.transfer.ts b/app/routes/api.boxes.transfer.ts index e1911bc9..d74e15e1 100644 --- a/app/routes/api.boxes.transfer.ts +++ b/app/routes/api.boxes.transfer.ts @@ -97,7 +97,7 @@ export const openapi: ZodOpenApiPathItemObject = { 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. `date` is supported as a legacy alias for `expiresAt`.', + '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: [] }], diff --git a/app/routes/api.users.me.boxes.$deviceId.ts b/app/routes/api.users.me.boxes.$deviceId.ts index 09e753bb..86c41179 100644 --- a/app/routes/api.users.me.boxes.$deviceId.ts +++ b/app/routes/api.users.me.boxes.$deviceId.ts @@ -22,31 +22,32 @@ import { internalServerErrorResponse, } from '~/lib/openapi/errors' -const CurrentUserPrivateBoxSchema = ApiDeviceSchema.meta({ - id: 'CurrentUserPrivateBox', +const CurrentUserPrivateDeviceSchema = ApiDeviceSchema.meta({ + id: 'CurrentUserPrivateDevice', description: - 'Box owned by the authenticated user. This response may include private or secret fields.', + 'Device owned by the authenticated user. This response may include private or secret fields.', }) -const GetCurrentUserBoxResponseSchema = z +const GetCurrentUserDeviceResponseSchema = z .object({ code: z.literal('Ok').default('Ok'), data: z.object({ - box: CurrentUserPrivateBoxSchema, + box: CurrentUserPrivateDeviceSchema, }), }) .meta({ id: 'GetCurrentUserBoxResponse', - description: 'Response containing one box owned by the authenticated user.', + description: + 'Response containing one device owned by the authenticated user.', }) export const openapi: ZodOpenApiPathItemObject = { get: { tags: ['User Management'], - summary: 'Get one box of the current user', + summary: 'Get one device of the current user', description: - 'Returns a specific box owned by the authenticated user. This endpoint may include private or secret fields that are not returned by public box endpoints.', - operationId: 'getCurrentUserBox', + '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: { @@ -55,10 +56,10 @@ export const openapi: ZodOpenApiPathItemObject = { responses: { 200: { - description: 'Box returned successfully.', + description: 'Device returned successfully.', content: { 'application/json': { - schema: GetCurrentUserBoxResponseSchema, + schema: GetCurrentUserDeviceResponseSchema, }, }, }, @@ -70,7 +71,7 @@ export const openapi: ZodOpenApiPathItemObject = { 403: forbiddenResponse( ForbiddenErrorSchema, - 'Invalid JWT authorization or the authenticated user does not own this senseBox.', + 'Invalid JWT authorization or the authenticated user does not own this device.', ), 500: internalServerErrorResponse( @@ -102,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.ts b/app/routes/api.users.me.ts index caca8512..db94379f 100644 --- a/app/routes/api.users.me.ts +++ b/app/routes/api.users.me.ts @@ -48,7 +48,7 @@ const GetMeResponseSchema = z const UpdateCurrentUserRequestSchema = z .object({ - email: z.string().trim().email().optional().meta({ + email: z.email().optional().meta({ description: 'New email address', example: 'newemail@example.com', }), From f17210810d4748268ca15713904ad9a2b625a1c4 Mon Sep 17 00:00:00 2001 From: jona159 Date: Thu, 28 May 2026 07:21:21 +0200 Subject: [PATCH 29/29] feat: rework integration docs --- app/lib/openapi.ts | 469 +------------------- app/lib/openapi/integrations/apis.ts | 202 +++++++++ app/lib/openapi/integrations/components.ts | 178 ++++++++ app/lib/openapi/integrations/description.ts | 64 +++ app/lib/openapi/integrations/examples.ts | 87 ++++ app/lib/openapi/integrations/index.ts | 50 +++ app/routes/docs.tsx | 6 +- 7 files changed, 588 insertions(+), 468 deletions(-) create mode 100644 app/lib/openapi/integrations/apis.ts create mode 100644 app/lib/openapi/integrations/components.ts create mode 100644 app/lib/openapi/integrations/description.ts create mode 100644 app/lib/openapi/integrations/examples.ts create mode 100644 app/lib/openapi/integrations/index.ts diff --git a/app/lib/openapi.ts b/app/lib/openapi.ts index e66f5944..8bf46d19 100644 --- a/app/lib/openapi.ts +++ b/app/lib/openapi.ts @@ -1,8 +1,5 @@ -import { - ZodOpenApiObject, - ZodOpenApiPathItemObject, - ZodOpenApiPathsObject, -} from 'zod-openapi' +import { ZodOpenApiPathItemObject, ZodOpenApiPathsObject } from 'zod-openapi' +import { generateIntegrationApiSpec } from './openapi/integrations' const DEV_SERVER = { url: 'http://localhost:3000', @@ -71,464 +68,4 @@ export const generateOpenApiServerSpec = (): { ] } -export const generateIntegrationApiSpec = (): ZodOpenApiObject => { - return { - openapi: '3.1.0', - info: { - title: 'API Schema for Integrations', - 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: { - bearerAuth: { - type: 'http', - scheme: 'bearer', - bearerFormat: 'JWT', - description: 'JWT access token obtained from the sign-in endpoint', - }, - 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', - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - } -} +export { generateIntegrationApiSpec } diff --git a/app/lib/openapi/integrations/apis.ts b/app/lib/openapi/integrations/apis.ts new file mode 100644 index 00000000..5d483db2 --- /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 00000000..faf01c51 --- /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 00000000..a682acac --- /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 00000000..919f29b6 --- /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 00000000..b458243b --- /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/routes/docs.tsx b/app/routes/docs.tsx index b808a15f..045a51c2 100644 --- a/app/routes/docs.tsx +++ b/app/routes/docs.tsx @@ -66,9 +66,11 @@ export default function ApiDocumentation() { - {spec.info.title} + + openSenseMap REST API + - {integrationSpec.info.title} + Integration Service Contract