Add OpenAPI schema generation and validation for API routes
This commit is contained in:
1 parent
5d822e6c7e
commit
5ce5664b29
25 files changed
+2019
-52
No files matched your search
@@ -23,5 +23,6 @@ jobs:
|
||||
node-version: 22
|
||||
cache: "pnpm"
|
||||
- run: pnpm install
|
||||
- run: pnpm openapi:check
|
||||
- run: pnpm test
|
||||
- run: pnpm build
|
||||
+1
-1
@@ -6,7 +6,7 @@
|
||||
"useIgnoreFile": true
|
||||
},
|
||||
"files": {
|
||||
"includes": ["**", "!!**/dist"]
|
||||
"includes": ["**", "!!**/dist", "!!public/openapi.json"]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
|
||||
+7
-3
@@ -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"
|
||||
}
|
||||
}
|
||||
Generated
+13
@@ -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)):
|
||||
|
||||
+1000
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
@@ -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' });
|
||||
@@ -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.' },
|
||||
}),
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.`;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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.',
|
||||
);
|
||||
@@ -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: [] }];
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
Reference in new issue
Block a user