diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b05776cd9..a36d77227 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,5 +23,6 @@ jobs: node-version: 22 cache: "pnpm" - run: pnpm install + - run: pnpm openapi:check - run: pnpm test - run: pnpm build diff --git a/biome.json b/biome.json index 61d094cac..c97e842a8 100644 --- a/biome.json +++ b/biome.json @@ -6,7 +6,7 @@ "useIgnoreFile": true }, "files": { - "includes": ["**", "!!**/dist"] + "includes": ["**", "!!**/dist", "!!public/openapi.json"] }, "formatter": { "enabled": true, diff --git a/package.json b/package.json index 26bc90c4b..a914b3ce6 100644 --- a/package.json +++ b/package.json @@ -12,9 +12,9 @@ "type": "module", "scripts": { "dev": "dotenv next dev --turbo", - "build": "npm-run-all check-env build-db check-db build-tracker build-recorder build-geo build-app", + "build": "npm-run-all check-env build-db check-db build-tracker build-recorder build-geo build-openapi build-app", "start": "next start", - "build-docker": "npm-run-all build-db build-tracker build-recorder build-geo build-app", + "build-docker": "npm-run-all build-db build-tracker build-recorder build-geo build-openapi build-app", "start-docker": "npm-run-all check-db update-tracker start-server", "start-env": "node scripts/start-env.js", "start-server": "node server.js", @@ -29,6 +29,7 @@ "build-prisma-client": "node scripts/build-prisma-client.js", "build-lang": "npm-run-all download-country-names download-language-names", "build-geo": "node scripts/build-geo.js", + "build-openapi": "pnpm openapi:generate", "build-db": "npm-run-all build-db-client build-prisma-client", "build-db-schema": "prisma db pull", "build-db-client": "prisma generate", @@ -41,6 +42,8 @@ "download-country-names": "node scripts/download-country-names.js", "download-language-names": "node scripts/download-language-names.js", "change-password": "node scripts/change-password.js", + "openapi:generate": "tsx scripts/generate-openapi.ts", + "openapi:check": "tsx scripts/check-openapi.ts", "postbuild": "node scripts/postbuild.js", "pretest": "pnpm build-db-client", "test": "vitest run", @@ -145,6 +148,7 @@ "tsup": "^8.5.0", "tsx": "^4.23.1", "typescript": "^6.0.3", - "vitest": "^4.1.10" + "vitest": "^4.1.10", + "zod-openapi": "^6.0.1" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ec908597a..553507115 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -273,6 +273,9 @@ importers: vitest: specifier: ^4.1.10 version: 4.1.10(@types/node@26.1.2)(jsdom@30.0.0(@noble/hashes@2.2.0))(msw@2.15.0(@types/node@26.1.2)(typescript@6.0.3))(vite@8.0.11(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.46.1)(tsx@4.23.1)) + zod-openapi: + specifier: ^6.0.1 + version: 6.0.1(zod@4.4.3) packages: @@ -5942,6 +5945,12 @@ packages: zeptomatch@2.1.0: resolution: {integrity: sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA==} + zod-openapi@6.0.1: + resolution: {integrity: sha512-ITUfaevP/JcrsOavyVHFQHz788F7YcmN8wFYRZk20UM16BUafdUzxq8ZM4ahyTv3c6ESvY9ywA+MwLXzIvVVAw==} + engines: {node: '>=22.14.0'} + peerDependencies: + zod: ^4.0.0 + zod@4.4.3: resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} @@ -11548,6 +11557,10 @@ snapshots: grammex: 3.1.13 graphmatch: 1.1.1 + zod-openapi@6.0.1(zod@4.4.3): + dependencies: + zod: 4.4.3 + zod@4.4.3: {} zustand@5.0.14(@types/react@19.2.17)(immer@11.1.15)(react@19.2.8)(use-sync-external-store@1.6.0(react@19.2.8)): diff --git a/public/openapi.json b/public/openapi.json new file mode 100644 index 000000000..39f010443 --- /dev/null +++ b/public/openapi.json @@ -0,0 +1,1000 @@ +{ + "components": { + "responses": { + "BadRequestResponse": { + "content": { + "application/json": { + "example": { + "error": { + "code": "bad-request", + "message": "Bad request.", + "status": 400 + } + }, + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Bad request." + }, + "ServerErrorResponse": { + "content": { + "application/json": { + "example": { + "error": { + "code": "server-error", + "message": "Server error.", + "status": 500 + } + }, + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Server error." + }, + "ServiceUnavailableResponse": { + "content": { + "application/json": { + "example": { + "error": { + "code": "service-unavailable", + "message": "Service unavailable.", + "status": 503 + } + }, + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Service unavailable." + }, + "UnauthorizedResponse": { + "content": { + "application/json": { + "example": { + "error": { + "code": "unauthorized", + "message": "Unauthorized.", + "status": 401 + } + }, + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Unauthorized." + } + }, + "schemas": { + "ApiError": { + "additionalProperties": false, + "description": "Standard Umami API error response.", + "properties": { + "error": { + "additionalProperties": {}, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "maximum": 9007199254740991, + "minimum": -9007199254740991, + "type": "integer" + } + }, + "required": [ + "message", + "code", + "status" + ], + "type": "object" + } + }, + "required": [ + "error" + ], + "type": "object" + }, + "CreateWebsiteRequest": { + "properties": { + "domain": { + "maxLength": 500, + "pattern": "^(localhost(:[1-9]\\d{0,4})?|((?=[a-z0-9-_]{1,63}\\.)(xn--)?[a-z0-9-_]+(-[a-z0-9-_]+)*\\.)+(xn--)?[a-z0-9-_]{2,63})$", + "type": "string" + }, + "id": { + "anyOf": [ + { + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "type": "string" + }, + { + "type": "null" + } + ] + }, + "name": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "shareId": { + "anyOf": [ + { + "maxLength": 50, + "type": "string" + }, + { + "type": "null" + } + ] + }, + "teamId": { + "anyOf": [ + { + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "name", + "domain" + ], + "type": "object" + }, + "LoginRequest": { + "properties": { + "password": { + "description": "Umami password.", + "type": "string" + }, + "username": { + "description": "Umami username.", + "type": "string" + } + }, + "required": [ + "username", + "password" + ], + "type": "object" + }, + "LoginResponse": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "partialToken": { + "type": "string" + }, + "requiresTwoFactor": { + "const": true, + "type": "boolean" + } + }, + "required": [ + "requiresTwoFactor", + "partialToken" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "token": { + "type": "string" + }, + "user": { + "$ref": "#/components/schemas/LoginUser" + } + }, + "required": [ + "token", + "user" + ], + "type": "object" + } + ] + }, + "LoginTeam": { + "additionalProperties": false, + "properties": { + "id": { + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "type": "string" + }, + "logoUrl": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "logoUrl" + ], + "type": "object" + }, + "LoginUser": { + "additionalProperties": false, + "properties": { + "createdAt": { + "anyOf": [ + { + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "type": "string" + }, + { + "type": "null" + } + ] + }, + "id": { + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "type": "string" + }, + "isAdmin": { + "type": "boolean" + }, + "role": { + "type": "string" + }, + "teams": { + "items": { + "$ref": "#/components/schemas/LoginTeam" + }, + "type": "array" + }, + "username": { + "type": "string" + } + }, + "required": [ + "id", + "username", + "role", + "createdAt", + "isAdmin", + "teams" + ], + "type": "object" + }, + "Ok": { + "additionalProperties": false, + "description": "Successful operation response.", + "properties": { + "ok": { + "const": true, + "type": "boolean" + } + }, + "required": [ + "ok" + ], + "type": "object" + }, + "ReplayConfig": { + "additionalProperties": {}, + "properties": { + "blockSelector": { + "type": "string" + }, + "heatmapEnabled": { + "type": "boolean" + }, + "heatmapSampleRate": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "maskLevel": { + "enum": [ + "strict", + "moderate" + ], + "type": "string" + }, + "maxDuration": { + "exclusiveMinimum": 0, + "maximum": 9007199254740991, + "type": "integer" + }, + "replayEnabled": { + "type": "boolean" + }, + "sampleRate": { + "maximum": 1, + "minimum": 0, + "type": "number" + } + }, + "type": "object" + }, + "ReplayConfigInput": { + "properties": { + "blockSelector": { + "type": "string" + }, + "heatmapEnabled": { + "type": "boolean" + }, + "heatmapSampleRate": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "maskLevel": { + "enum": [ + "strict", + "moderate" + ], + "type": "string" + }, + "maxDuration": { + "exclusiveMinimum": 0, + "maximum": 9007199254740991, + "type": "integer" + }, + "replayEnabled": { + "type": "boolean" + }, + "sampleRate": { + "maximum": 1, + "minimum": 0, + "type": "number" + } + }, + "type": "object" + }, + "UpdateWebsiteRequest": { + "properties": { + "domain": { + "maxLength": 500, + "pattern": "^(localhost(:[1-9]\\d{0,4})?|((?=[a-z0-9-_]{1,63}\\.)(xn--)?[a-z0-9-_]+(-[a-z0-9-_]+)*\\.)+(xn--)?[a-z0-9-_]{2,63})$", + "type": "string" + }, + "name": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "replayConfig": { + "anyOf": [ + { + "$ref": "#/components/schemas/ReplayConfigInput" + }, + { + "type": "null" + } + ] + }, + "shareId": { + "anyOf": [ + { + "maxLength": 50, + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, + "Website": { + "additionalProperties": false, + "properties": { + "createdAt": { + "anyOf": [ + { + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "type": "string" + }, + { + "type": "null" + } + ] + }, + "createdBy": { + "anyOf": [ + { + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "type": "string" + }, + { + "type": "null" + } + ] + }, + "deletedAt": { + "anyOf": [ + { + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "type": "string" + }, + { + "type": "null" + } + ] + }, + "domain": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "id": { + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "type": "string" + }, + "name": { + "type": "string" + }, + "recorderEnabled": { + "type": "boolean" + }, + "replayConfig": { + "anyOf": [ + { + "$ref": "#/components/schemas/ReplayConfig" + }, + { + "type": "null" + } + ] + }, + "resetAt": { + "anyOf": [ + { + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "type": "string" + }, + { + "type": "null" + } + ] + }, + "shareId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "teamId": { + "anyOf": [ + { + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "type": "string" + }, + { + "type": "null" + } + ] + }, + "updatedAt": { + "anyOf": [ + { + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "type": "string" + }, + { + "type": "null" + } + ] + }, + "user": { + "$ref": "#/components/schemas/WebsiteUser" + }, + "userId": { + "anyOf": [ + { + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "name", + "domain", + "resetAt", + "userId", + "teamId", + "createdBy", + "createdAt", + "updatedAt", + "deletedAt", + "recorderEnabled", + "replayConfig", + "shareId" + ], + "type": "object" + }, + "WebsitePage": { + "additionalProperties": false, + "properties": { + "count": { + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "data": { + "items": { + "$ref": "#/components/schemas/Website" + }, + "type": "array" + }, + "orderBy": { + "type": "string" + }, + "page": { + "exclusiveMinimum": 0, + "maximum": 9007199254740991, + "type": "integer" + }, + "pageSize": { + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "search": { + "type": "string" + } + }, + "required": [ + "data", + "count", + "page", + "pageSize" + ], + "type": "object" + }, + "WebsiteUser": { + "additionalProperties": false, + "properties": { + "id": { + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "type": "string" + }, + "username": { + "type": "string" + } + }, + "required": [ + "id", + "username" + ], + "type": "object" + } + }, + "securitySchemes": { + "bearerAuth": { + "bearerFormat": "JWT", + "description": "Token returned by POST /api/auth/login.", + "scheme": "bearer", + "type": "http" + }, + "shareContext": { + "description": "Required context header when authenticating with a share token.", + "in": "header", + "name": "x-umami-share-context", + "type": "apiKey" + }, + "shareToken": { + "description": "Signed token returned by a share endpoint.", + "in": "header", + "name": "x-umami-share-token", + "type": "apiKey" + } + } + }, + "externalDocs": { + "description": "Umami API documentation", + "url": "https://docs.umami.is/docs/api" + }, + "info": { + "description": "REST API for Umami analytics. Self-hosted deployments serve these paths beneath their configured BASE_PATH.", + "license": { + "identifier": "MIT", + "name": "MIT" + }, + "title": "Umami API", + "version": "3.3.0" + }, + "openapi": "3.1.0", + "paths": { + "/api/auth/login": { + "post": { + "description": "Authenticates a self-hosted Umami user. Users with two-factor authentication receive a short-lived partial token instead of a full bearer token.", + "operationId": "login", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginResponse" + } + } + }, + "description": "Authenticated successfully." + }, + "400": { + "$ref": "#/components/responses/BadRequestResponse" + }, + "401": { + "$ref": "#/components/responses/UnauthorizedResponse" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailableResponse" + } + }, + "security": [], + "summary": "Log in", + "tags": [ + "Authentication" + ], + "x-umami-audience": "public" + } + }, + "/api/websites": { + "get": { + "description": "Returns websites owned by the authenticated user.", + "operationId": "listWebsites", + "parameters": [ + { + "in": "query", + "name": "page", + "schema": { + "exclusiveMinimum": 0, + "maximum": 9007199254740991, + "type": "integer" + } + }, + { + "in": "query", + "name": "pageSize", + "schema": { + "exclusiveMinimum": 0, + "maximum": 9007199254740991, + "type": "integer" + } + }, + { + "in": "query", + "name": "maxResults", + "schema": { + "exclusiveMinimum": 0, + "maximum": 9007199254740991, + "type": "integer" + } + }, + { + "in": "query", + "name": "search", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "orderBy", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "sortDescending", + "schema": { + "enum": [ + "true", + "false" + ], + "type": "string" + } + }, + { + "description": "When present, include websites accessible through owned or managed teams.", + "in": "query", + "name": "includeTeams", + "schema": { + "description": "When present, include websites accessible through owned or managed teams.", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebsitePage" + } + } + }, + "description": "A page of websites." + }, + "400": { + "$ref": "#/components/responses/BadRequestResponse" + }, + "401": { + "$ref": "#/components/responses/UnauthorizedResponse" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "List websites", + "tags": [ + "Websites" + ], + "x-umami-audience": "public" + }, + "post": { + "operationId": "createWebsite", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateWebsiteRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Website" + } + } + }, + "description": "Website created." + }, + "400": { + "$ref": "#/components/responses/BadRequestResponse" + }, + "401": { + "$ref": "#/components/responses/UnauthorizedResponse" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Create a website", + "tags": [ + "Websites" + ], + "x-umami-audience": "public" + } + }, + "/api/websites/{websiteId}": { + "delete": { + "operationId": "deleteWebsite", + "parameters": [ + { + "description": "Website ID.", + "in": "path", + "name": "websiteId", + "required": true, + "schema": { + "description": "Website ID.", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Ok" + } + } + }, + "description": "Website deleted." + }, + "401": { + "$ref": "#/components/responses/UnauthorizedResponse" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Delete a website", + "tags": [ + "Websites" + ], + "x-umami-audience": "public" + }, + "get": { + "operationId": "getWebsite", + "parameters": [ + { + "description": "Website ID.", + "in": "path", + "name": "websiteId", + "required": true, + "schema": { + "description": "Website ID.", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/Website" + }, + { + "type": "null" + } + ] + } + } + }, + "description": "Website details, or null if it does not exist." + }, + "401": { + "$ref": "#/components/responses/UnauthorizedResponse" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "shareContext": [], + "shareToken": [] + } + ], + "summary": "Get a website", + "tags": [ + "Websites" + ], + "x-umami-audience": "public" + }, + "post": { + "operationId": "updateWebsite", + "parameters": [ + { + "description": "Website ID.", + "in": "path", + "name": "websiteId", + "required": true, + "schema": { + "description": "Website ID.", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateWebsiteRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Website" + } + } + }, + "description": "Website updated." + }, + "400": { + "$ref": "#/components/responses/BadRequestResponse" + }, + "401": { + "$ref": "#/components/responses/UnauthorizedResponse" + }, + "500": { + "$ref": "#/components/responses/ServerErrorResponse" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Update a website", + "tags": [ + "Websites" + ], + "x-umami-audience": "public" + } + } + }, + "tags": [ + { + "name": "Authentication" + }, + { + "name": "Websites" + } + ] +} diff --git a/scripts/check-openapi.ts b/scripts/check-openapi.ts new file mode 100644 index 000000000..328a3408d --- /dev/null +++ b/scripts/check-openapi.ts @@ -0,0 +1,46 @@ +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { formatCoverageSummary } from '@/openapi/coverage'; +import { buildOpenApiDocument } from '@/openapi/document'; +import { getOperationKey } from '@/openapi/operation'; +import { serializeOpenApiDocument } from '@/openapi/serialize'; + +const projectRoot = process.cwd(); +const outputFile = path.join(projectRoot, 'public', 'openapi.json'); +const strict = process.argv.includes('--strict') || process.env.OPENAPI_REQUIRE_COMPLETE === '1'; +const verbose = process.argv.includes('--verbose'); +const { document, coverage } = await buildOpenApiDocument('public', projectRoot); +const expected = serializeOpenApiDocument(document); +let actual = ''; + +try { + actual = await readFile(outputFile, 'utf8'); +} catch { + // The comparison below reports the missing generated artifact. +} + +let failed = false; + +if (actual !== expected) { + console.error( + `${path.relative(projectRoot, outputFile)} is stale or missing. Run \`pnpm openapi:generate\`.`, + ); + failed = true; +} + +console.log(formatCoverageSummary(coverage)); + +if (verbose && coverage.missing.length) { + coverage.missing.forEach(operation => { + console.log(` ${getOperationKey(operation)} (${operation.source})`); + }); +} + +if (strict && coverage.missing.length) { + console.error('Strict OpenAPI coverage requires an explicit contract for every API operation.'); + failed = true; +} + +if (failed) { + process.exitCode = 1; +} diff --git a/scripts/generate-openapi.ts b/scripts/generate-openapi.ts new file mode 100644 index 000000000..0abe894d0 --- /dev/null +++ b/scripts/generate-openapi.ts @@ -0,0 +1,15 @@ +import { mkdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { formatCoverageSummary } from '@/openapi/coverage'; +import { buildOpenApiDocument } from '@/openapi/document'; +import { serializeOpenApiDocument } from '@/openapi/serialize'; + +const projectRoot = process.cwd(); +const outputFile = path.join(projectRoot, 'public', 'openapi.json'); +const { document, coverage } = await buildOpenApiDocument('public', projectRoot); + +await mkdir(path.dirname(outputFile), { recursive: true }); +await writeFile(outputFile, serializeOpenApiDocument(document), 'utf8'); + +console.log(`Generated ${path.relative(projectRoot, outputFile)}.`); +console.log(formatCoverageSummary(coverage)); diff --git a/src/app/api/auth/login/contract.ts b/src/app/api/auth/login/contract.ts new file mode 100644 index 000000000..e710ae963 --- /dev/null +++ b/src/app/api/auth/login/contract.ts @@ -0,0 +1,71 @@ +import { z } from 'zod'; +import { defineOperation } from '@/openapi/operation'; +import { + badRequestResponse, + jsonResponse, + serviceUnavailableResponse, + unauthorizedResponse, +} from '@/openapi/schemas'; +import { loginRequestSchema } from './schema'; + +const loginTeamSchema = z + .object({ + id: z.uuid(), + name: z.string(), + logoUrl: z.string().nullable(), + }) + .meta({ id: 'LoginTeam' }); + +const loginUserSchema = z + .object({ + id: z.uuid(), + username: z.string(), + role: z.string(), + createdAt: z.iso.datetime().nullable(), + isAdmin: z.boolean(), + teams: z.array(loginTeamSchema), + }) + .meta({ id: 'LoginUser' }); + +export const loginResponseSchema = z + .union([ + z.object({ + requiresTwoFactor: z.literal(true), + partialToken: z.string(), + }), + z.object({ + token: z.string(), + user: loginUserSchema, + }), + ]) + .meta({ id: 'LoginResponse' }); + +const loginOperation = defineOperation({ + method: 'post', + path: '/api/auth/login', + audience: 'public', + auth: 'none', + operation: { + operationId: 'login', + summary: 'Log in', + description: + 'Authenticates a self-hosted Umami user. Users with two-factor authentication receive a short-lived partial token instead of a full bearer token.', + tags: ['Authentication'], + requestBody: { + required: true, + content: { + 'application/json': { + schema: loginRequestSchema, + }, + }, + }, + responses: { + '200': jsonResponse(loginResponseSchema, 'Authenticated successfully.'), + '400': badRequestResponse, + '401': unauthorizedResponse, + '503': serviceUnavailableResponse, + }, + }, +}); + +export const operations = [loginOperation] as const; diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index bb63e0fba..fb7cdd21a 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -1,4 +1,3 @@ -import { z } from 'zod'; import { saveAuth } from '@/lib/auth'; import { ROLES } from '@/lib/constants'; import { hash, secret } from '@/lib/crypto'; @@ -10,14 +9,10 @@ import { parseRequest } from '@/lib/request'; import { json, serviceUnavailable, unauthorized } from '@/lib/response'; import { getTwoFactorConfigurationError, isTwoFactorConfigured } from '@/lib/two-factor/crypto'; import { getAllUserTeams, getUserByUsername } from '@/queries/prisma'; +import { loginRequestSchema } from './schema'; export async function POST(request: Request) { - const schema = z.object({ - username: z.string(), - password: z.string(), - }); - - const { body, error } = await parseRequest(request, schema, { skipAuth: true }); + const { body, error } = await parseRequest(request, loginRequestSchema, { skipAuth: true }); if (error) { return error(); diff --git a/src/app/api/auth/login/schema.ts b/src/app/api/auth/login/schema.ts new file mode 100644 index 000000000..0c72f76e9 --- /dev/null +++ b/src/app/api/auth/login/schema.ts @@ -0,0 +1,8 @@ +import { z } from 'zod'; + +export const loginRequestSchema = z + .object({ + username: z.string().meta({ description: 'Umami username.' }), + password: z.string().meta({ description: 'Umami password.' }), + }) + .meta({ id: 'LoginRequest' }); diff --git a/src/app/api/websites/[websiteId]/contract.ts b/src/app/api/websites/[websiteId]/contract.ts new file mode 100644 index 000000000..7bfef32ea --- /dev/null +++ b/src/app/api/websites/[websiteId]/contract.ts @@ -0,0 +1,86 @@ +import { defineOperation } from '@/openapi/operation'; +import { + badRequestResponse, + jsonResponse, + okSchema, + serverErrorResponse, + unauthorizedResponse, +} from '@/openapi/schemas'; +import { updateWebsiteRequestSchema } from '../request-schema'; +import { websiteIdPathSchema, websiteSchema } from '../response-schema'; + +const getWebsiteOperation = defineOperation({ + method: 'get', + path: '/api/websites/{websiteId}', + audience: 'public', + auth: 'bearer-or-share', + operation: { + operationId: 'getWebsite', + summary: 'Get a website', + tags: ['Websites'], + requestParams: { + path: websiteIdPathSchema, + }, + responses: { + '200': jsonResponse( + websiteSchema.nullable(), + 'Website details, or null if it does not exist.', + ), + '401': unauthorizedResponse, + }, + }, +}); + +const updateWebsiteOperation = defineOperation({ + method: 'post', + path: '/api/websites/{websiteId}', + audience: 'public', + auth: 'bearer', + operation: { + operationId: 'updateWebsite', + summary: 'Update a website', + tags: ['Websites'], + requestParams: { + path: websiteIdPathSchema, + }, + requestBody: { + required: true, + content: { + 'application/json': { + schema: updateWebsiteRequestSchema, + }, + }, + }, + responses: { + '200': jsonResponse(websiteSchema, 'Website updated.'), + '400': badRequestResponse, + '401': unauthorizedResponse, + '500': serverErrorResponse, + }, + }, +}); + +const deleteWebsiteOperation = defineOperation({ + method: 'delete', + path: '/api/websites/{websiteId}', + audience: 'public', + auth: 'bearer', + operation: { + operationId: 'deleteWebsite', + summary: 'Delete a website', + tags: ['Websites'], + requestParams: { + path: websiteIdPathSchema, + }, + responses: { + '200': jsonResponse(okSchema, 'Website deleted.'), + '401': unauthorizedResponse, + }, + }, +}); + +export const operations = [ + getWebsiteOperation, + updateWebsiteOperation, + deleteWebsiteOperation, +] as const; diff --git a/src/app/api/websites/[websiteId]/route.ts b/src/app/api/websites/[websiteId]/route.ts index 74cdf3b39..6a8b5448d 100644 --- a/src/app/api/websites/[websiteId]/route.ts +++ b/src/app/api/websites/[websiteId]/route.ts @@ -1,6 +1,5 @@ -import { z } from 'zod'; import type { Prisma } from '@/generated/prisma/client'; -import { DOMAIN_REGEX, ENTITY_TYPE } from '@/lib/constants'; +import { ENTITY_TYPE } from '@/lib/constants'; import { uuid } from '@/lib/crypto'; import { getRecorderConfig, getRecorderEnabled } from '@/lib/recorder'; import { parseRequest } from '@/lib/request'; @@ -14,6 +13,7 @@ import { getWebsite, updateWebsite, } from '@/queries/prisma'; +import { updateWebsiteRequestSchema } from '../request-schema'; export async function GET( request: Request, @@ -40,25 +40,7 @@ export async function POST( request: Request, { params }: { params: Promise<{ websiteId: string }> }, ) { - const schema = z.object({ - name: z.string().trim().min(1).max(100).optional(), - domain: z.string().trim().regex(DOMAIN_REGEX).max(500).optional(), - shareId: z.string().max(50).nullable().optional(), - replayConfig: z - .object({ - replayEnabled: z.boolean().optional(), - heatmapEnabled: z.boolean().optional(), - sampleRate: z.number().min(0).max(1).optional(), - heatmapSampleRate: z.number().min(0).max(1).optional(), - maskLevel: z.enum(['strict', 'moderate']).optional(), - maxDuration: z.number().int().positive().optional(), - blockSelector: z.string().optional(), - }) - .nullable() - .optional(), - }); - - const { auth, body, error } = await parseRequest(request, schema); + const { auth, body, error } = await parseRequest(request, updateWebsiteRequestSchema); if (error) { return error(); diff --git a/src/app/api/websites/contract.ts b/src/app/api/websites/contract.ts new file mode 100644 index 000000000..ba76f11ac --- /dev/null +++ b/src/app/api/websites/contract.ts @@ -0,0 +1,52 @@ +import { defineOperation } from '@/openapi/operation'; +import { badRequestResponse, jsonResponse, unauthorizedResponse } from '@/openapi/schemas'; +import { createWebsiteRequestSchema, listWebsitesQuerySchema } from './request-schema'; +import { websitePageSchema, websiteSchema } from './response-schema'; + +const listWebsitesOperation = defineOperation({ + method: 'get', + path: '/api/websites', + audience: 'public', + auth: 'bearer', + operation: { + operationId: 'listWebsites', + summary: 'List websites', + description: 'Returns websites owned by the authenticated user.', + tags: ['Websites'], + requestParams: { + query: listWebsitesQuerySchema, + }, + responses: { + '200': jsonResponse(websitePageSchema, 'A page of websites.'), + '400': badRequestResponse, + '401': unauthorizedResponse, + }, + }, +}); + +const createWebsiteOperation = defineOperation({ + method: 'post', + path: '/api/websites', + audience: 'public', + auth: 'bearer', + operation: { + operationId: 'createWebsite', + summary: 'Create a website', + tags: ['Websites'], + requestBody: { + required: true, + content: { + 'application/json': { + schema: createWebsiteRequestSchema, + }, + }, + }, + responses: { + '200': jsonResponse(websiteSchema, 'Website created.'), + '400': badRequestResponse, + '401': unauthorizedResponse, + }, + }, +}); + +export const operations = [listWebsitesOperation, createWebsiteOperation] as const; diff --git a/src/app/api/websites/request-schema.ts b/src/app/api/websites/request-schema.ts new file mode 100644 index 000000000..63f7581e4 --- /dev/null +++ b/src/app/api/websites/request-schema.ts @@ -0,0 +1,45 @@ +import { z } from 'zod'; +import { DOMAIN_REGEX } from '@/lib/constants'; +import { pagingParams, searchParams, sortingParams } from '@/lib/schema'; + +export const replayConfigInputSchema = z + .object({ + replayEnabled: z.boolean().optional(), + heatmapEnabled: z.boolean().optional(), + sampleRate: z.number().min(0).max(1).optional(), + heatmapSampleRate: z.number().min(0).max(1).optional(), + maskLevel: z.enum(['strict', 'moderate']).optional(), + maxDuration: z.number().int().positive().optional(), + blockSelector: z.string().optional(), + }) + .meta({ id: 'ReplayConfigInput' }); + +export const listWebsitesQuerySchema = z + .object({ + ...pagingParams, + ...searchParams, + ...sortingParams, + includeTeams: z.string().optional().meta({ + description: 'When present, include websites accessible through owned or managed teams.', + }), + }) + .meta({ id: 'ListWebsitesQuery' }); + +export const createWebsiteRequestSchema = z + .object({ + name: z.string().trim().min(1).max(100), + domain: z.string().trim().regex(DOMAIN_REGEX).max(500), + shareId: z.string().max(50).nullable().optional(), + teamId: z.uuid().nullable().optional(), + id: z.uuid().nullable().optional(), + }) + .meta({ id: 'CreateWebsiteRequest' }); + +export const updateWebsiteRequestSchema = z + .object({ + name: z.string().trim().min(1).max(100).optional(), + domain: z.string().trim().regex(DOMAIN_REGEX).max(500).optional(), + shareId: z.string().max(50).nullable().optional(), + replayConfig: replayConfigInputSchema.nullable().optional(), + }) + .meta({ id: 'UpdateWebsiteRequest' }); diff --git a/src/app/api/websites/response-schema.ts b/src/app/api/websites/response-schema.ts new file mode 100644 index 000000000..616f344a2 --- /dev/null +++ b/src/app/api/websites/response-schema.ts @@ -0,0 +1,60 @@ +import { z } from 'zod'; + +const nullableUuidSchema = z.uuid().nullable(); +const nullableDateTimeSchema = z.iso.datetime().nullable(); + +export const replayConfigSchema = z + .looseObject({ + replayEnabled: z.boolean().optional(), + heatmapEnabled: z.boolean().optional(), + sampleRate: z.number().min(0).max(1).optional(), + heatmapSampleRate: z.number().min(0).max(1).optional(), + maskLevel: z.enum(['strict', 'moderate']).optional(), + maxDuration: z.number().int().positive().optional(), + blockSelector: z.string().optional(), + }) + .meta({ id: 'ReplayConfig' }); + +const websiteUserSchema = z + .object({ + id: z.uuid(), + username: z.string(), + }) + .meta({ id: 'WebsiteUser' }); + +export const websiteSchema = z + .object({ + id: z.uuid(), + name: z.string(), + domain: z.string().nullable(), + resetAt: nullableDateTimeSchema, + userId: nullableUuidSchema, + teamId: nullableUuidSchema, + createdBy: nullableUuidSchema, + createdAt: nullableDateTimeSchema, + updatedAt: nullableDateTimeSchema, + deletedAt: nullableDateTimeSchema, + recorderEnabled: z.boolean(), + replayConfig: replayConfigSchema.nullable(), + shareId: z.string().nullable(), + user: websiteUserSchema.optional(), + }) + .meta({ id: 'Website' }); + +export const websitePageSchema = z + .object({ + data: z.array(websiteSchema), + count: z.number().int().nonnegative(), + page: z.number().int().positive(), + pageSize: z.number().int().nonnegative(), + orderBy: z.string().optional(), + search: z.string().optional(), + }) + .meta({ id: 'WebsitePage' }); + +export const websiteIdPathSchema = z.object({ + websiteId: z.uuid().meta({ + description: 'Website ID.', + param: { description: 'Website ID.' }, + }), +}); diff --git a/src/app/api/websites/route.ts b/src/app/api/websites/route.ts index 91222e2ae..be4abe539 100644 --- a/src/app/api/websites/route.ts +++ b/src/app/api/websites/route.ts @@ -1,24 +1,16 @@ -import { z } from 'zod'; -import { DOMAIN_REGEX, ENTITY_TYPE } from '@/lib/constants'; +import { ENTITY_TYPE } from '@/lib/constants'; import { uuid } from '@/lib/crypto'; import { fetchAccount, fetchTeam } from '@/lib/load'; import { getQueryFilters, parseRequest } from '@/lib/request'; import { json, unauthorized } from '@/lib/response'; -import { pagingParams, searchParams, sortingParams } from '@/lib/schema'; import { getCloudWebsiteLimit } from '@/lib/subscription'; import { canCreateTeamWebsite, canCreateWebsite } from '@/permissions'; import { createShare, createWebsite, getTeamWebsiteCount, getWebsiteCount } from '@/queries/prisma'; import { getAllUserWebsitesIncludingTeamAccess, getUserWebsites } from '@/queries/prisma/website'; +import { createWebsiteRequestSchema, listWebsitesQuerySchema } from './request-schema'; export async function GET(request: Request) { - const schema = z.object({ - ...pagingParams, - ...searchParams, - ...sortingParams, - includeTeams: z.string().optional(), - }); - - const { auth, query, error } = await parseRequest(request, schema); + const { auth, query, error } = await parseRequest(request, listWebsitesQuerySchema); if (error) { return error(); @@ -36,15 +28,7 @@ export async function GET(request: Request) { } export async function POST(request: Request) { - const schema = z.object({ - name: z.string().trim().min(1).max(100), - domain: z.string().trim().regex(DOMAIN_REGEX).max(500), - shareId: z.string().max(50).nullable().optional(), - teamId: z.uuid().nullable().optional(), - id: z.uuid().nullable().optional(), - }); - - const { auth, body, error } = await parseRequest(request, schema); + const { auth, body, error } = await parseRequest(request, createWebsiteRequestSchema); if (error) { return error(); diff --git a/src/openapi/README.md b/src/openapi/README.md new file mode 100644 index 000000000..604d4b8c4 --- /dev/null +++ b/src/openapi/README.md @@ -0,0 +1,34 @@ +# OpenAPI contracts + +Umami generates `public/openapi.json` from Zod-backed contracts colocated with App Router API +handlers. + +## Commands + +- `pnpm openapi:generate` regenerates the public OpenAPI document. +- `pnpm openapi:check` validates contract alignment and checks that the generated file is current. +- `pnpm openapi:check --strict` also fails while any non-OPTIONS API route lacks an explicit + contract. +- `pnpm openapi:check --verbose` lists routes that still need contracts. + +The normal and Docker builds run the generator before the Next.js build. CI runs the check before +tests and build steps. + +## Adding an operation + +Create a `contract.ts` next to the matching `route.ts` and export an `operations` array. Define +request and response shapes with Zod, and import the same request schema into the route handler for +`parseRequest` validation. + +Contract modules must remain safe to import from a build script. They must not import route +handlers, database clients, Redis clients, or modules with environment-dependent side effects. + +Every operation declares an audience: + +- `public` operations are emitted to `public/openapi.json`. +- `internal` operations are available when building an unfiltered document. +- `collect` operations identify tracker ingestion APIs. + +Route paths and HTTP methods are discovered independently from `src/app/api/**/route.ts`. The check +rejects duplicate contracts, duplicate operation IDs, orphaned contracts, and mismatched dynamic +path parameters. diff --git a/src/openapi/contracts.ts b/src/openapi/contracts.ts new file mode 100644 index 000000000..711c1a908 --- /dev/null +++ b/src/openapi/contracts.ts @@ -0,0 +1,33 @@ +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { discoverContractFiles } from '@/openapi/discover'; +import type { ApiOperationContract, LoadedApiOperationContract } from '@/openapi/operation'; + +interface ContractModule { + operations?: readonly ApiOperationContract[]; +} + +export async function loadApiContracts( + projectRoot = process.cwd(), +): Promise { + const contractFiles = await discoverContractFiles(projectRoot); + const contracts: LoadedApiOperationContract[] = []; + + for (const contractFile of contractFiles) { + const module = (await import(pathToFileURL(contractFile).href)) as ContractModule; + + if (!Array.isArray(module.operations)) { + throw new Error( + `${path.relative(projectRoot, contractFile)} must export an \`operations\` array.`, + ); + } + + const source = path.relative(projectRoot, contractFile).replaceAll(path.sep, '/'); + + module.operations.forEach(operation => { + contracts.push({ ...operation, source }); + }); + } + + return contracts; +} diff --git a/src/openapi/coverage.ts b/src/openapi/coverage.ts new file mode 100644 index 000000000..592d6ebab --- /dev/null +++ b/src/openapi/coverage.ts @@ -0,0 +1,115 @@ +import type { DiscoveredApiOperation } from '@/openapi/discover'; +import { + type ApiOperationContract, + getOperationKey, + type LoadedApiOperationContract, +} from '@/openapi/operation'; + +export interface ContractCoverage { + discovered: DiscoveredApiOperation[]; + contracted: LoadedApiOperationContract[]; + missing: DiscoveredApiOperation[]; + orphaned: LoadedApiOperationContract[]; + duplicateContracts: string[]; + duplicateOperationIds: string[]; + pathParameterMismatches: string[]; +} + +function getExpectedPathParameters(path: string) { + return [...path.matchAll(/\{([^}]+)}/g)].map(match => match[1]).sort(); +} + +function getContractPathParameters(contract: ApiOperationContract) { + const schema = contract.operation.requestParams?.path as { shape?: unknown } | undefined; + const shape = schema?.shape; + + if (!shape || typeof shape !== 'object') { + return []; + } + + return Object.keys(shape).sort(); +} + +function arraysEqual(left: string[], right: string[]) { + return left.length === right.length && left.every((value, index) => value === right[index]); +} + +export function analyzeContractCoverage( + discovered: DiscoveredApiOperation[], + contracted: LoadedApiOperationContract[], +): ContractCoverage { + const relevantRoutes = discovered.filter(operation => operation.method !== 'options'); + const discoveredKeys = new Set(relevantRoutes.map(getOperationKey)); + const contractsByKey = new Map(); + const contractsByOperationId = new Map(); + + contracted.forEach(contract => { + const key = getOperationKey(contract); + const operationId = contract.operation.operationId; + + contractsByKey.set(key, [...(contractsByKey.get(key) ?? []), contract]); + + if (operationId) { + contractsByOperationId.set(operationId, [ + ...(contractsByOperationId.get(operationId) ?? []), + contract, + ]); + } + }); + + const duplicateContracts = [...contractsByKey.entries()] + .filter(([, contracts]) => contracts.length > 1) + .map(([key, contracts]) => `${key} (${contracts.map(contract => contract.source).join(', ')})`) + .sort(); + const duplicateOperationIds = [...contractsByOperationId.entries()] + .filter(([, contracts]) => contracts.length > 1) + .map( + ([operationId, contracts]) => + `${operationId} (${contracts.map(contract => contract.source).join(', ')})`, + ) + .sort(); + const contractedKeys = new Set(contractsByKey.keys()); + const missing = relevantRoutes.filter( + operation => !contractedKeys.has(getOperationKey(operation)), + ); + const orphaned = contracted.filter(contract => !discoveredKeys.has(getOperationKey(contract))); + const pathParameterMismatches = contracted + .map(contract => { + const expected = getExpectedPathParameters(contract.path); + const actual = getContractPathParameters(contract); + + if (arraysEqual(expected, actual)) { + return null; + } + + return `${getOperationKey(contract)} declares [${actual.join(', ')}], expected [${expected.join(', ')}] (${contract.source})`; + }) + .filter((message): message is string => message !== null) + .sort(); + + return { + discovered: relevantRoutes, + contracted, + missing, + orphaned, + duplicateContracts, + duplicateOperationIds, + pathParameterMismatches, + }; +} + +export function getCoverageErrors(coverage: ContractCoverage) { + return [ + ...coverage.duplicateContracts.map(message => `Duplicate contract: ${message}`), + ...coverage.duplicateOperationIds.map(message => `Duplicate operationId: ${message}`), + ...coverage.orphaned.map( + contract => + `Contract does not match a route: ${getOperationKey(contract)} (${contract.source})`, + ), + ...coverage.pathParameterMismatches.map(message => `Path parameter mismatch: ${message}`), + ]; +} + +export function formatCoverageSummary(coverage: ContractCoverage) { + return `${coverage.contracted.length}/${coverage.discovered.length} API operations have explicit OpenAPI contracts; ${coverage.missing.length} remain.`; +} diff --git a/src/openapi/discover.ts b/src/openapi/discover.ts new file mode 100644 index 000000000..114d19847 --- /dev/null +++ b/src/openapi/discover.ts @@ -0,0 +1,131 @@ +import { readdir, readFile } from 'node:fs/promises'; +import path from 'node:path'; +import ts from 'typescript'; +import { API_HTTP_METHODS, type ApiHttpMethod } from '@/openapi/operation'; + +const ROUTE_FILE_PATTERN = /^route\.(?:js|jsx|ts|tsx)$/; +const METHOD_NAMES = new Set(API_HTTP_METHODS.map(method => method.toUpperCase())); + +export interface DiscoveredApiOperation { + method: ApiHttpMethod; + path: `/${string}`; + source: string; +} + +async function walk(directory: string, predicate: (name: string) => boolean): Promise { + const entries = await readdir(directory, { withFileTypes: true }); + const files = await Promise.all( + entries.map(async entry => { + const entryPath = path.join(directory, entry.name); + + if (entry.isDirectory()) { + return walk(entryPath, predicate); + } + + return predicate(entry.name) ? [entryPath] : []; + }), + ); + + return files.flat(); +} + +function hasExportModifier(node: ts.Node) { + return ts.canHaveModifiers(node) + ? ts.getModifiers(node)?.some(modifier => modifier.kind === ts.SyntaxKind.ExportKeyword) + : false; +} + +function getExportedMethods(source: ts.SourceFile): ApiHttpMethod[] { + const methods = new Set(); + + const addMethod = (name: string) => { + if (METHOD_NAMES.has(name)) { + methods.add(name.toLowerCase() as ApiHttpMethod); + } + }; + + source.statements.forEach(statement => { + if (ts.isFunctionDeclaration(statement) && statement.name && hasExportModifier(statement)) { + addMethod(statement.name.text); + return; + } + + if (ts.isVariableStatement(statement) && hasExportModifier(statement)) { + statement.declarationList.declarations.forEach(declaration => { + if (ts.isIdentifier(declaration.name)) { + addMethod(declaration.name.text); + } + }); + return; + } + + if (ts.isExportDeclaration(statement) && statement.exportClause) { + if (ts.isNamedExports(statement.exportClause)) { + statement.exportClause.elements.forEach(element => { + addMethod(element.name.text); + }); + } + } + }); + + return API_HTTP_METHODS.filter(method => methods.has(method)); +} + +function toOpenApiSegment(segment: string) { + if ((segment.startsWith('(') && segment.endsWith(')')) || segment.startsWith('@')) { + return null; + } + + const optionalCatchAll = segment.match(/^\[\[\.\.\.(.+)]]$/); + const catchAll = segment.match(/^\[\.\.\.(.+)]$/); + const dynamic = segment.match(/^\[(.+)]$/); + const name = optionalCatchAll?.[1] ?? catchAll?.[1] ?? dynamic?.[1]; + + return name ? `{${name}}` : segment; +} + +function getRoutePath(apiRoot: string, routeFile: string): `/${string}` { + const relativeDirectory = path.relative(apiRoot, path.dirname(routeFile)); + const segments = relativeDirectory + .split(path.sep) + .filter(Boolean) + .map(toOpenApiSegment) + .filter((segment): segment is string => segment !== null); + + return `/api${segments.length ? `/${segments.join('/')}` : ''}`; +} + +export async function discoverApiOperations( + projectRoot = process.cwd(), +): Promise { + const apiRoot = path.join(projectRoot, 'src', 'app', 'api'); + const routeFiles = await walk(apiRoot, name => ROUTE_FILE_PATTERN.test(name)); + const operations: DiscoveredApiOperation[] = []; + + for (const routeFile of routeFiles.sort()) { + const content = await readFile(routeFile, 'utf8'); + const source = ts.createSourceFile( + routeFile, + content, + ts.ScriptTarget.Latest, + true, + routeFile.endsWith('x') ? ts.ScriptKind.TSX : ts.ScriptKind.TS, + ); + const routePath = getRoutePath(apiRoot, routeFile); + const relativeSource = path.relative(projectRoot, routeFile).replaceAll(path.sep, '/'); + + getExportedMethods(source).forEach(method => { + operations.push({ method, path: routePath, source: relativeSource }); + }); + } + + return operations.sort((left, right) => + `${left.path}:${left.method}`.localeCompare(`${right.path}:${right.method}`), + ); +} + +export async function discoverContractFiles(projectRoot = process.cwd()) { + const apiRoot = path.join(projectRoot, 'src', 'app', 'api'); + + return (await walk(apiRoot, name => name === 'contract.ts')).sort(); +} diff --git a/src/openapi/document.ts b/src/openapi/document.ts new file mode 100644 index 000000000..595de64bf --- /dev/null +++ b/src/openapi/document.ts @@ -0,0 +1,115 @@ +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { createDocument, type ZodOpenApiPathsObject } from 'zod-openapi'; +import { loadApiContracts } from '@/openapi/contracts'; +import { + analyzeContractCoverage, + type ContractCoverage, + getCoverageErrors, +} from '@/openapi/coverage'; +import { discoverApiOperations } from '@/openapi/discover'; +import type { ApiAudience } from '@/openapi/operation'; +import { getSecurityRequirements, securitySchemes } from '@/openapi/security'; + +export type DocumentAudience = ApiAudience | 'all'; + +export interface OpenApiBuildResult { + document: ReturnType; + coverage: ContractCoverage; +} + +async function getPackageVersion(projectRoot: string) { + const packageJson = JSON.parse( + await readFile(path.join(projectRoot, 'package.json'), 'utf8'), + ) as { version: string }; + + return packageJson.version; +} + +function getDocumentTags(paths: ZodOpenApiPathsObject) { + const names = new Set(); + + Object.values(paths).forEach(pathItem => { + Object.values(pathItem).forEach(value => { + if (value && typeof value === 'object' && 'tags' in value && Array.isArray(value.tags)) { + value.tags.forEach(tag => { + names.add(tag); + }); + } + }); + }); + + return [...names].sort().map(name => ({ name })); +} + +export async function buildOpenApiDocument( + audience: DocumentAudience = 'public', + projectRoot = process.cwd(), +): Promise { + const [contracts, discovered, version] = await Promise.all([ + loadApiContracts(projectRoot), + discoverApiOperations(projectRoot), + getPackageVersion(projectRoot), + ]); + const coverage = analyzeContractCoverage(discovered, contracts); + const errors = getCoverageErrors(coverage); + + if (errors.length) { + throw new Error(`Invalid OpenAPI contracts:\n${errors.map(error => `- ${error}`).join('\n')}`); + } + + const selectedContracts = contracts + .filter(contract => audience === 'all' || contract.audience === audience) + .sort((left, right) => + `${left.path}:${left.method}`.localeCompare(`${right.path}:${right.method}`), + ); + const paths: ZodOpenApiPathsObject = {}; + + selectedContracts.forEach(contract => { + let pathItem = paths[contract.path]; + + if (!pathItem) { + pathItem = {}; + paths[contract.path] = pathItem; + } + + const operation = { + ...contract.operation, + security: getSecurityRequirements(contract.auth), + 'x-umami-audience': contract.audience, + }; + + Object.assign(pathItem, { [contract.method]: operation }); + }); + + const document = createDocument( + { + openapi: '3.1.0', + info: { + title: 'Umami API', + version, + description: + 'REST API for Umami analytics. Self-hosted deployments serve these paths beneath their configured BASE_PATH.', + license: { + name: 'MIT', + identifier: 'MIT', + }, + }, + externalDocs: { + description: 'Umami API documentation', + url: 'https://docs.umami.is/docs/api', + }, + tags: getDocumentTags(paths), + paths, + components: { + securitySchemes, + }, + }, + { + reused: 'inline', + cycles: 'ref', + }, + ); + + return { document, coverage }; +} diff --git a/src/openapi/operation.ts b/src/openapi/operation.ts new file mode 100644 index 000000000..0908102c1 --- /dev/null +++ b/src/openapi/operation.ts @@ -0,0 +1,35 @@ +import type { ZodOpenApiOperationObject } from 'zod-openapi'; + +export const API_HTTP_METHODS = [ + 'get', + 'post', + 'put', + 'patch', + 'delete', + 'head', + 'options', +] as const; + +export type ApiHttpMethod = (typeof API_HTTP_METHODS)[number]; +export type ApiAudience = 'public' | 'internal' | 'collect'; +export type ApiAuth = 'none' | 'bearer' | 'bearer-or-share'; + +export interface ApiOperationContract { + method: ApiHttpMethod; + path: `/${string}`; + audience: ApiAudience; + auth: ApiAuth; + operation: ZodOpenApiOperationObject; +} + +export interface LoadedApiOperationContract extends ApiOperationContract { + source: string; +} + +export function defineOperation(contract: T): T { + return contract; +} + +export function getOperationKey({ method, path }: Pick) { + return `${method.toUpperCase()} ${path}`; +} diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts new file mode 100644 index 000000000..958026b40 --- /dev/null +++ b/src/openapi/schemas.ts @@ -0,0 +1,89 @@ +import { z } from 'zod'; +import type { ZodOpenApiResponseObject } from 'zod-openapi'; + +export const apiErrorSchema = z + .object({ + error: z.looseObject({ + message: z.string(), + code: z.string(), + status: z.number().int(), + }), + }) + .meta({ id: 'ApiError', description: 'Standard Umami API error response.' }); + +export const okSchema = z + .object({ + ok: z.literal(true), + }) + .meta({ id: 'Ok', description: 'Successful operation response.' }); + +export function jsonResponse( + schema: z.ZodType, + description = 'Successful response.', +): ZodOpenApiResponseObject { + return { + description, + content: { + 'application/json': { + schema, + }, + }, + }; +} + +function errorResponse( + id: string, + status: number, + code: string, + description: string, +): ZodOpenApiResponseObject { + return { + id, + description, + content: { + 'application/json': { + schema: apiErrorSchema, + example: { + error: { + message: description, + code, + status, + }, + }, + }, + }, + }; +} + +export const badRequestResponse = errorResponse( + 'BadRequestResponse', + 400, + 'bad-request', + 'Bad request.', +); +export const unauthorizedResponse = errorResponse( + 'UnauthorizedResponse', + 401, + 'unauthorized', + 'Unauthorized.', +); +export const forbiddenResponse = errorResponse('ForbiddenResponse', 403, 'forbidden', 'Forbidden.'); +export const notFoundResponse = errorResponse('NotFoundResponse', 404, 'not-found', 'Not found.'); +export const payloadTooLargeResponse = errorResponse( + 'PayloadTooLargeResponse', + 413, + 'payload-too-large', + 'Payload too large.', +); +export const serverErrorResponse = errorResponse( + 'ServerErrorResponse', + 500, + 'server-error', + 'Server error.', +); +export const serviceUnavailableResponse = errorResponse( + 'ServiceUnavailableResponse', + 503, + 'service-unavailable', + 'Service unavailable.', +); diff --git a/src/openapi/security.ts b/src/openapi/security.ts new file mode 100644 index 000000000..4195fa44f --- /dev/null +++ b/src/openapi/security.ts @@ -0,0 +1,34 @@ +import type { ApiAuth } from '@/openapi/operation'; + +export const securitySchemes = { + bearerAuth: { + type: 'http' as const, + scheme: 'bearer', + bearerFormat: 'JWT', + description: 'Token returned by POST /api/auth/login.', + }, + shareToken: { + type: 'apiKey' as const, + in: 'header' as const, + name: 'x-umami-share-token', + description: 'Signed token returned by a share endpoint.', + }, + shareContext: { + type: 'apiKey' as const, + in: 'header' as const, + name: 'x-umami-share-context', + description: 'Required context header when authenticating with a share token.', + }, +}; + +export function getSecurityRequirements(auth: ApiAuth) { + if (auth === 'none') { + return []; + } + + if (auth === 'bearer-or-share') { + return [{ bearerAuth: [] }, { shareToken: [], shareContext: [] }]; + } + + return [{ bearerAuth: [] }]; +} diff --git a/src/openapi/serialize.ts b/src/openapi/serialize.ts new file mode 100644 index 000000000..5c66b083d --- /dev/null +++ b/src/openapi/serialize.ts @@ -0,0 +1,19 @@ +function sortJson(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(sortJson); + } + + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, child]) => [key, sortJson(child)]), + ); + } + + return value; +} + +export function serializeOpenApiDocument(document: unknown) { + return `${JSON.stringify(sortJson(document), null, 2)}\n`; +}