Add OpenAPI schema generation and validation for API routes

This commit is contained in:
Mike Cao committed 2026-08-31 19:37:37 -07:00
1 parent 5d822e6c7e
commit 5ce5664b29
25 files changed
+2019 -52

No files matched your search

+1
View File
@@ -23,5 +23,6 @@ jobs:
node-version: 22 node-version: 22
cache: "pnpm" cache: "pnpm"
- run: pnpm install - run: pnpm install
- run: pnpm openapi:check
- run: pnpm test - run: pnpm test
- run: pnpm build - run: pnpm build
+1 -1
View File
@@ -6,7 +6,7 @@
"useIgnoreFile": true "useIgnoreFile": true
}, },
"files": { "files": {
"includes": ["**", "!!**/dist"] "includes": ["**", "!!**/dist", "!!public/openapi.json"]
}, },
"formatter": { "formatter": {
"enabled": true, "enabled": true,
+7 -3
View File
@@ -12,9 +12,9 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "dotenv next dev --turbo", "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", "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-docker": "npm-run-all check-db update-tracker start-server",
"start-env": "node scripts/start-env.js", "start-env": "node scripts/start-env.js",
"start-server": "node server.js", "start-server": "node server.js",
@@ -29,6 +29,7 @@
"build-prisma-client": "node scripts/build-prisma-client.js", "build-prisma-client": "node scripts/build-prisma-client.js",
"build-lang": "npm-run-all download-country-names download-language-names", "build-lang": "npm-run-all download-country-names download-language-names",
"build-geo": "node scripts/build-geo.js", "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": "npm-run-all build-db-client build-prisma-client",
"build-db-schema": "prisma db pull", "build-db-schema": "prisma db pull",
"build-db-client": "prisma generate", "build-db-client": "prisma generate",
@@ -41,6 +42,8 @@
"download-country-names": "node scripts/download-country-names.js", "download-country-names": "node scripts/download-country-names.js",
"download-language-names": "node scripts/download-language-names.js", "download-language-names": "node scripts/download-language-names.js",
"change-password": "node scripts/change-password.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", "postbuild": "node scripts/postbuild.js",
"pretest": "pnpm build-db-client", "pretest": "pnpm build-db-client",
"test": "vitest run", "test": "vitest run",
@@ -145,6 +148,7 @@
"tsup": "^8.5.0", "tsup": "^8.5.0",
"tsx": "^4.23.1", "tsx": "^4.23.1",
"typescript": "^6.0.3", "typescript": "^6.0.3",
"vitest": "^4.1.10" "vitest": "^4.1.10",
"zod-openapi": "^6.0.1"
} }
} }
+13
View File
@@ -273,6 +273,9 @@ importers:
vitest: vitest:
specifier: ^4.1.10 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)) 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: packages:
@@ -5942,6 +5945,12 @@ packages:
zeptomatch@2.1.0: zeptomatch@2.1.0:
resolution: {integrity: sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA==} 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: zod@4.4.3:
resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
@@ -11548,6 +11557,10 @@ snapshots:
grammex: 3.1.13 grammex: 3.1.13
graphmatch: 1.1.1 graphmatch: 1.1.1
zod-openapi@6.0.1(zod@4.4.3):
dependencies:
zod: 4.4.3
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)): 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)):
+1000
View File
@@ -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"
}
]
}
+46
View File
@@ -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;
}
+15
View File
@@ -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));
+71
View File
@@ -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;
+2 -7
View File
@@ -1,4 +1,3 @@
import { z } from 'zod';
import { saveAuth } from '@/lib/auth'; import { saveAuth } from '@/lib/auth';
import { ROLES } from '@/lib/constants'; import { ROLES } from '@/lib/constants';
import { hash, secret } from '@/lib/crypto'; import { hash, secret } from '@/lib/crypto';
@@ -10,14 +9,10 @@ import { parseRequest } from '@/lib/request';
import { json, serviceUnavailable, unauthorized } from '@/lib/response'; import { json, serviceUnavailable, unauthorized } from '@/lib/response';
import { getTwoFactorConfigurationError, isTwoFactorConfigured } from '@/lib/two-factor/crypto'; import { getTwoFactorConfigurationError, isTwoFactorConfigured } from '@/lib/two-factor/crypto';
import { getAllUserTeams, getUserByUsername } from '@/queries/prisma'; import { getAllUserTeams, getUserByUsername } from '@/queries/prisma';
import { loginRequestSchema } from './schema';
export async function POST(request: Request) { export async function POST(request: Request) {
const schema = z.object({ const { body, error } = await parseRequest(request, loginRequestSchema, { skipAuth: true });
username: z.string(),
password: z.string(),
});
const { body, error } = await parseRequest(request, schema, { skipAuth: true });
if (error) { if (error) {
return error(); return error();
+8
View File
@@ -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' });
@@ -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;
+3 -21
View File
@@ -1,6 +1,5 @@
import { z } from 'zod';
import type { Prisma } from '@/generated/prisma/client'; 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 { uuid } from '@/lib/crypto';
import { getRecorderConfig, getRecorderEnabled } from '@/lib/recorder'; import { getRecorderConfig, getRecorderEnabled } from '@/lib/recorder';
import { parseRequest } from '@/lib/request'; import { parseRequest } from '@/lib/request';
@@ -14,6 +13,7 @@ import {
getWebsite, getWebsite,
updateWebsite, updateWebsite,
} from '@/queries/prisma'; } from '@/queries/prisma';
import { updateWebsiteRequestSchema } from '../request-schema';
export async function GET( export async function GET(
request: Request, request: Request,
@@ -40,25 +40,7 @@ export async function POST(
request: Request, request: Request,
{ params }: { params: Promise<{ websiteId: string }> }, { params }: { params: Promise<{ websiteId: string }> },
) { ) {
const schema = z.object({ const { auth, body, error } = await parseRequest(request, updateWebsiteRequestSchema);
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);
if (error) { if (error) {
return error(); return error();
+52
View File
@@ -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;
+45
View File
@@ -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' });
+60
View File
@@ -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.' },
}),
});
+4 -20
View File
@@ -1,24 +1,16 @@
import { z } from 'zod'; import { ENTITY_TYPE } from '@/lib/constants';
import { DOMAIN_REGEX, ENTITY_TYPE } from '@/lib/constants';
import { uuid } from '@/lib/crypto'; import { uuid } from '@/lib/crypto';
import { fetchAccount, fetchTeam } from '@/lib/load'; import { fetchAccount, fetchTeam } from '@/lib/load';
import { getQueryFilters, parseRequest } from '@/lib/request'; import { getQueryFilters, parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response'; import { json, unauthorized } from '@/lib/response';
import { pagingParams, searchParams, sortingParams } from '@/lib/schema';
import { getCloudWebsiteLimit } from '@/lib/subscription'; import { getCloudWebsiteLimit } from '@/lib/subscription';
import { canCreateTeamWebsite, canCreateWebsite } from '@/permissions'; import { canCreateTeamWebsite, canCreateWebsite } from '@/permissions';
import { createShare, createWebsite, getTeamWebsiteCount, getWebsiteCount } from '@/queries/prisma'; import { createShare, createWebsite, getTeamWebsiteCount, getWebsiteCount } from '@/queries/prisma';
import { getAllUserWebsitesIncludingTeamAccess, getUserWebsites } from '@/queries/prisma/website'; import { getAllUserWebsitesIncludingTeamAccess, getUserWebsites } from '@/queries/prisma/website';
import { createWebsiteRequestSchema, listWebsitesQuerySchema } from './request-schema';
export async function GET(request: Request) { export async function GET(request: Request) {
const schema = z.object({ const { auth, query, error } = await parseRequest(request, listWebsitesQuerySchema);
...pagingParams,
...searchParams,
...sortingParams,
includeTeams: z.string().optional(),
});
const { auth, query, error } = await parseRequest(request, schema);
if (error) { if (error) {
return error(); return error();
@@ -36,15 +28,7 @@ export async function GET(request: Request) {
} }
export async function POST(request: Request) { export async function POST(request: Request) {
const schema = z.object({ const { auth, body, error } = await parseRequest(request, createWebsiteRequestSchema);
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);
if (error) { if (error) {
return error(); return error();
+34
View File
@@ -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.
+33
View File
@@ -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<LoadedApiOperationContract[]> {
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;
}
+115
View File
@@ -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<string, LoadedApiOperationContract[]>();
const contractsByOperationId = new Map<string, LoadedApiOperationContract[]>();
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.`;
}
+131
View File
@@ -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<string[]> {
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<ApiHttpMethod>();
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<DiscoveredApiOperation[]> {
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();
}
+115
View File
@@ -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<typeof createDocument>;
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<string>();
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<OpenApiBuildResult> {
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 };
}
+35
View File
@@ -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<const T extends ApiOperationContract>(contract: T): T {
return contract;
}
export function getOperationKey({ method, path }: Pick<ApiOperationContract, 'method' | 'path'>) {
return `${method.toUpperCase()} ${path}`;
}
+89
View File
@@ -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.',
);
+34
View File
@@ -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: [] }];
}
+19
View File
@@ -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`;
}