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
|
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
@@ -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
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Generated
+13
@@ -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
@@ -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 { 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();
|
||||||
|
|||||||
@@ -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 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();
|
||||||
|
|||||||
@@ -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 { 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();
|
||||||
|
|||||||
@@ -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