Add Swagger/OpenAPI API docs (swagger-ui + swagger-autogen)
Generate an OpenAPI 3.0 spec from route annotations and serve it with Swagger UI so the full REST API is browsable and testable. - Add swagger-ui-express (runtime) and swagger-autogen (dev) deps, plus an `npm run swagger` script. - server/swagger/swagger.js: generator config with API metadata, servers, 14 tag groups, cookie + bearer security schemes, and 28 reusable component schemas. Follows the Express mount chain from src/app.js so generated paths are fully-qualified (/api/v1/...). - Annotate every route (auth, mobile, sso, public, admin, health) with #swagger tags/summaries/parameters/request bodies/security and the actual response codes each handler returns (400/401/403/404/409/429/ 302/502, multipart uploads). - Serve Swagger UI at /api/docs and the raw spec at /api/docs.json, guarded so a missing spec disables docs instead of crashing. - Commit the generated swagger-output.json so docs work with no build step; swagger-autogen stays dev-only and is not needed at runtime. - README: new "API documentation (Swagger)" section plus tech-stack and project-structure entries. Covers 51 paths / 64 operations. Existing test suite (83) still passes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
6059
server/swagger/swagger-output.json
Normal file
6059
server/swagger/swagger-output.json
Normal file
File diff suppressed because it is too large
Load Diff
340
server/swagger/swagger.js
Normal file
340
server/swagger/swagger.js
Normal file
@@ -0,0 +1,340 @@
|
||||
// ── OpenAPI spec generator (swagger-autogen) ───────────────────────────────
|
||||
//
|
||||
// Static-analyzes the Express routers and emits `swagger-output.json`, which is
|
||||
// served by swagger-ui-express at /api/docs (see src/app.js). Per-endpoint
|
||||
// details — tags, summaries, parameters, request bodies, security and response
|
||||
// codes — live as `#swagger.*` comments next to each route in
|
||||
// src/router/**. This file supplies everything shared: API metadata, servers,
|
||||
// tag descriptions, the two auth schemes (session cookie + mobile bearer), and
|
||||
// the reusable component schemas the annotations reference.
|
||||
//
|
||||
// Regenerate with: npm run swagger (from the server/ directory)
|
||||
// The generated JSON is committed so the docs work without a build step.
|
||||
|
||||
const swaggerAutogen = require('swagger-autogen')({ openapi: '3.0.0' })
|
||||
const pkg = require('../package.json')
|
||||
|
||||
const outputFile = './swagger/swagger-output.json'
|
||||
|
||||
// Entry point of the routing graph. swagger-autogen follows the `app.use(...)`
|
||||
// mount chain from here (/api → /v1 → auth|public|admin), so generated paths are
|
||||
// fully-qualified (e.g. /api/v1/auth/login).
|
||||
const routes = ['./src/app.js']
|
||||
|
||||
const doc = {
|
||||
info: {
|
||||
title: 'UOMysticmoon API',
|
||||
version: pkg.version,
|
||||
description:
|
||||
'REST API for the UOMysticmoon website, wiki and admin panel — a private ' +
|
||||
'Ultima Online shard.\n\n' +
|
||||
'### Authentication\n' +
|
||||
'- **Web / admin panel** uses an httpOnly session cookie (`uomm_token`) issued by ' +
|
||||
'`POST /api/v1/auth/login` (plus `/login/totp` when 2FA is enabled).\n' +
|
||||
'- **Native / mobile clients** use bearer access tokens from ' +
|
||||
'`POST /api/v1/auth/mobile/login`, refreshed via `/auth/mobile/refresh`.\n\n' +
|
||||
'Endpoints under `/api/v1/admin/**` require a valid session; some are further ' +
|
||||
'restricted to the `admin` role (editors are limited to content).',
|
||||
},
|
||||
servers: [
|
||||
{ url: '/', description: 'Same-origin (current host)' },
|
||||
{ url: 'http://localhost:3000', description: 'Local development' },
|
||||
],
|
||||
tags: [
|
||||
{ name: 'Health', description: 'Liveness probe' },
|
||||
{ name: 'Auth', description: 'Web session login/logout (cookie + TOTP)' },
|
||||
{ name: 'Auth · Mobile', description: 'Native bearer-token login, refresh and logout' },
|
||||
{ name: 'Auth · SSO', description: 'OAuth2 / OIDC provider discovery and redirect flow' },
|
||||
{ name: 'Public', description: 'Unauthenticated site content (settings, posts, wiki, contact)' },
|
||||
{ name: 'Admin · Account', description: 'Self-service account security (2FA, linked identities)' },
|
||||
{ name: 'Admin · Dashboard', description: 'Dashboard summary and site mode' },
|
||||
{ name: 'Admin · Posts', description: 'News / five-on-friday / newsletter / screenshots + uploads' },
|
||||
{ name: 'Admin · Wiki', description: 'Wiki pages, categories, tags and revisions' },
|
||||
{ name: 'Admin · Settings', description: 'Site settings (admin only)' },
|
||||
{ name: 'Admin · Activity', description: 'Admin activity log' },
|
||||
{ name: 'Admin · Bot Activity', description: 'Bot-scoring/ban state and emergency unban (admin only)' },
|
||||
{ name: 'Admin · Auth Providers', description: 'SSO provider configuration (admin only)' },
|
||||
{ name: 'Admin · Users', description: 'User management (admin only)' },
|
||||
],
|
||||
components: {
|
||||
securitySchemes: {
|
||||
// Web/admin session — httpOnly cookie set by the login endpoints.
|
||||
cookieAuth: {
|
||||
type: 'apiKey',
|
||||
in: 'cookie',
|
||||
name: 'uomm_token',
|
||||
description: 'Session JWT set as an httpOnly cookie by POST /api/v1/auth/login.',
|
||||
},
|
||||
// Native/mobile clients — Authorization: Bearer <accessToken>.
|
||||
bearerAuth: {
|
||||
type: 'http',
|
||||
scheme: 'bearer',
|
||||
bearerFormat: 'JWT',
|
||||
description: 'Access token from POST /api/v1/auth/mobile/login (or /refresh).',
|
||||
},
|
||||
},
|
||||
schemas: {
|
||||
Error: {
|
||||
type: 'object',
|
||||
properties: { message: { type: 'string', example: 'Not found' } },
|
||||
},
|
||||
ValidationError: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
errors: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
type: { type: 'string', example: 'field' },
|
||||
msg: { type: 'string', example: 'Invalid value' },
|
||||
path: { type: 'string', example: 'username' },
|
||||
location: { type: 'string', example: 'body' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
SafeUser: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'integer', example: 1 },
|
||||
username: { type: 'string', example: 'admin' },
|
||||
role: { type: 'string', enum: ['admin', 'editor'], example: 'admin' },
|
||||
},
|
||||
},
|
||||
LoginRequest: {
|
||||
type: 'object',
|
||||
required: ['username', 'password'],
|
||||
properties: {
|
||||
username: { type: 'string', example: 'admin' },
|
||||
password: { type: 'string', format: 'password', example: 'super-secret' },
|
||||
company: { type: 'string', description: 'Honeypot — must be empty for humans.', example: '' },
|
||||
},
|
||||
},
|
||||
LoginResponse: {
|
||||
type: 'object',
|
||||
description:
|
||||
'Either a session (user) or, for 2FA accounts, a TOTP challenge to complete at /login/totp.',
|
||||
properties: {
|
||||
user: { $ref: '#/components/schemas/SafeUser' },
|
||||
totpRequired: { type: 'boolean', example: true },
|
||||
challenge: { type: 'string', description: 'Signed challenge token for the TOTP step.' },
|
||||
},
|
||||
},
|
||||
TotpLoginRequest: {
|
||||
type: 'object',
|
||||
required: ['challenge', 'code'],
|
||||
properties: {
|
||||
challenge: { type: 'string', description: 'Token returned by /login when totpRequired.' },
|
||||
code: { type: 'string', example: '123456' },
|
||||
},
|
||||
},
|
||||
MobileLoginRequest: {
|
||||
type: 'object',
|
||||
required: ['username', 'password'],
|
||||
properties: {
|
||||
username: { type: 'string', example: 'admin' },
|
||||
password: { type: 'string', format: 'password', example: 'super-secret' },
|
||||
code: { type: 'string', description: 'TOTP code (only when 2FA is enabled).', example: '123456' },
|
||||
},
|
||||
},
|
||||
MobileTokenResponse: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
accessToken: { type: 'string', description: 'Short-lived bearer JWT.' },
|
||||
refreshToken: { type: 'string', description: 'Long-lived, revocable refresh token.' },
|
||||
expiresIn: { type: 'integer', description: 'Access token lifetime in seconds.', example: 900 },
|
||||
user: { $ref: '#/components/schemas/SafeUser' },
|
||||
},
|
||||
},
|
||||
MobileRefreshRequest: {
|
||||
type: 'object',
|
||||
required: ['refreshToken'],
|
||||
properties: { refreshToken: { type: 'string' } },
|
||||
},
|
||||
MobileLogoutRequest: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
refreshToken: { type: 'string', description: 'Revoke a single session.' },
|
||||
all: { type: 'boolean', description: 'Revoke every session for the user.', example: false },
|
||||
},
|
||||
},
|
||||
Message: {
|
||||
type: 'object',
|
||||
properties: { message: { type: 'string', example: 'Logged out.' } },
|
||||
},
|
||||
ContactRequest: {
|
||||
type: 'object',
|
||||
required: ['message'],
|
||||
properties: {
|
||||
message: { type: 'string', maxLength: 5000, example: 'When does the shard launch?' },
|
||||
email: { type: 'string', format: 'email', example: 'player@example.com' },
|
||||
name: { type: 'string', maxLength: 100, example: 'Lord British' },
|
||||
},
|
||||
},
|
||||
Provider: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', example: 'google' },
|
||||
name: { type: 'string', example: 'Google' },
|
||||
kind: { type: 'string', enum: ['oidc', 'oauth2'], example: 'oidc' },
|
||||
},
|
||||
},
|
||||
ProviderConfig: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', example: 'okta' },
|
||||
kind: { type: 'string', enum: ['oidc', 'oauth2'], example: 'oidc' },
|
||||
name: { type: 'string', example: 'Okta' },
|
||||
enabled: { type: 'boolean', example: true },
|
||||
clientId: { type: 'string' },
|
||||
authorizeUrl: { type: 'string', format: 'uri' },
|
||||
tokenUrl: { type: 'string', format: 'uri' },
|
||||
userinfoUrl: { type: 'string', format: 'uri' },
|
||||
scopes: { type: 'string', example: 'openid email profile' },
|
||||
priority: { type: 'integer', example: 10 },
|
||||
},
|
||||
},
|
||||
ProviderCreateRequest: {
|
||||
type: 'object',
|
||||
required: ['id', 'kind', 'name'],
|
||||
properties: {
|
||||
id: { type: 'string', pattern: '^[a-z0-9-]+$', example: 'okta' },
|
||||
kind: { type: 'string', enum: ['oidc', 'oauth2'], example: 'oidc' },
|
||||
name: { type: 'string', maxLength: 80, example: 'Okta' },
|
||||
enabled: { type: 'boolean', example: true },
|
||||
clientId: { type: 'string' },
|
||||
secret: { type: 'string', format: 'password' },
|
||||
authorizeUrl: { type: 'string', format: 'uri' },
|
||||
tokenUrl: { type: 'string', format: 'uri' },
|
||||
userinfoUrl: { type: 'string', format: 'uri' },
|
||||
scopes: { type: 'string', maxLength: 500, example: 'openid email profile' },
|
||||
priority: { type: 'integer', example: 10 },
|
||||
},
|
||||
},
|
||||
Post: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'integer', example: 12 },
|
||||
category: { type: 'string', example: 'news' },
|
||||
title: { type: 'string', example: 'Server maintenance this weekend' },
|
||||
slug: { type: 'string', example: 'server-maintenance-this-weekend' },
|
||||
body: { type: 'string' },
|
||||
image_url: { type: 'string', example: '/uploads/1700000000-abcd.png' },
|
||||
published: { type: 'boolean', example: true },
|
||||
created_at: { type: 'string', format: 'date-time' },
|
||||
updated_at: { type: 'string', format: 'date-time' },
|
||||
},
|
||||
},
|
||||
PostCreateRequest: {
|
||||
type: 'object',
|
||||
required: ['category', 'title'],
|
||||
properties: {
|
||||
category: { type: 'string', example: 'news' },
|
||||
title: { type: 'string', maxLength: 200, example: 'Server maintenance this weekend' },
|
||||
body: { type: 'string' },
|
||||
image_url: { type: 'string', description: 'Required for the screenshots category.' },
|
||||
published: { type: 'boolean', example: false },
|
||||
},
|
||||
},
|
||||
PublishRequest: {
|
||||
type: 'object',
|
||||
required: ['published'],
|
||||
properties: { published: { type: 'boolean', example: true } },
|
||||
},
|
||||
UploadResponse: {
|
||||
type: 'object',
|
||||
properties: { url: { type: 'string', example: '/uploads/1700000000-abcd.png' } },
|
||||
},
|
||||
WikiPage: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'integer', example: 3 },
|
||||
slug: { type: 'string', example: 'getting-started' },
|
||||
title: { type: 'string', example: 'Getting Started' },
|
||||
excerpt: { type: 'string' },
|
||||
body: { type: 'string' },
|
||||
category_id: { type: 'integer', nullable: true, example: 2 },
|
||||
published: { type: 'boolean', example: true },
|
||||
tags: { type: 'array', items: { type: 'string' }, example: ['newbie', 'guide'] },
|
||||
created_at: { type: 'string', format: 'date-time' },
|
||||
updated_at: { type: 'string', format: 'date-time' },
|
||||
},
|
||||
},
|
||||
WikiPageCreateRequest: {
|
||||
type: 'object',
|
||||
required: ['slug', 'title'],
|
||||
properties: {
|
||||
slug: { type: 'string', pattern: '^[a-z0-9-]+$', example: 'getting-started' },
|
||||
title: { type: 'string', maxLength: 200, example: 'Getting Started' },
|
||||
excerpt: { type: 'string', maxLength: 400 },
|
||||
body: { type: 'string' },
|
||||
category_id: { type: 'integer', nullable: true },
|
||||
published: { type: 'boolean', example: false },
|
||||
tags: { type: 'array', items: { type: 'string' } },
|
||||
},
|
||||
},
|
||||
WikiCategory: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'integer', example: 2 },
|
||||
slug: { type: 'string', example: 'guides' },
|
||||
title: { type: 'string', example: 'Guides' },
|
||||
description: { type: 'string' },
|
||||
sort_order: { type: 'integer', example: 0 },
|
||||
},
|
||||
},
|
||||
WikiCategoryCreateRequest: {
|
||||
type: 'object',
|
||||
required: ['slug', 'title'],
|
||||
properties: {
|
||||
slug: { type: 'string', pattern: '^[a-z0-9-]+$', example: 'guides' },
|
||||
title: { type: 'string', maxLength: 200, example: 'Guides' },
|
||||
description: { type: 'string', maxLength: 400 },
|
||||
sort_order: { type: 'integer', example: 0 },
|
||||
},
|
||||
},
|
||||
User: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'integer', example: 1 },
|
||||
username: { type: 'string', example: 'admin' },
|
||||
role: { type: 'string', enum: ['admin', 'editor'], example: 'admin' },
|
||||
totp_enabled: { type: 'boolean', example: true },
|
||||
last_login_at: { type: 'string', format: 'date-time', nullable: true },
|
||||
created_at: { type: 'string', format: 'date-time' },
|
||||
},
|
||||
},
|
||||
UserCreateRequest: {
|
||||
type: 'object',
|
||||
required: ['username', 'password'],
|
||||
properties: {
|
||||
username: { type: 'string', minLength: 3, maxLength: 32, example: 'editor1' },
|
||||
password: { type: 'string', format: 'password', minLength: 8, maxLength: 64 },
|
||||
role: { type: 'string', enum: ['admin', 'editor'], example: 'editor' },
|
||||
},
|
||||
},
|
||||
TotpCodeRequest: {
|
||||
type: 'object',
|
||||
required: ['code'],
|
||||
properties: { code: { type: 'string', example: '123456' } },
|
||||
},
|
||||
SiteModeRequest: {
|
||||
type: 'object',
|
||||
required: ['mode'],
|
||||
properties: { mode: { type: 'string', enum: ['live', 'maintenance'], example: 'live' } },
|
||||
},
|
||||
UnbanRequest: {
|
||||
type: 'object',
|
||||
required: ['ip'],
|
||||
properties: { ip: { type: 'string', example: '203.0.113.5' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
swaggerAutogen(outputFile, routes, doc).then(() => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('swagger-output.json generated.')
|
||||
})
|
||||
Reference in New Issue
Block a user