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:
@@ -9,10 +9,32 @@ const { contactLimiter } = require('../../../middleware/rateLimit')
|
||||
const publicRouter = express.Router()
|
||||
|
||||
// Always available (so the client can render the maintenance page + contact).
|
||||
publicRouter.get('/settings', ctrl.getSettings)
|
||||
publicRouter.get('/status', ctrl.getStatus)
|
||||
publicRouter.get(
|
||||
'/settings',
|
||||
// #swagger.tags = ['Public']
|
||||
// #swagger.summary = 'Public site settings'
|
||||
// #swagger.description = 'Whitelisted, non-sensitive settings the client needs to render the site.'
|
||||
/* #swagger.responses[200] = { description: 'Key/value settings', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
ctrl.getSettings,
|
||||
)
|
||||
publicRouter.get(
|
||||
'/status',
|
||||
// #swagger.tags = ['Public']
|
||||
// #swagger.summary = 'Site mode / status'
|
||||
// #swagger.description = 'Current site mode (live or maintenance) so the client can show the maintenance page.'
|
||||
/* #swagger.responses[200] = { description: 'Site status', content: { "application/json": { schema: { type: "object", properties: { mode: { type: "string", example: "live" } } } } } } */
|
||||
ctrl.getStatus,
|
||||
)
|
||||
publicRouter.post(
|
||||
'/contact',
|
||||
// #swagger.tags = ['Public']
|
||||
// #swagger.summary = 'Send a contact message'
|
||||
// #swagger.description = 'Emails the site owner (or falls back to a mailto). Rate limited.'
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/ContactRequest" } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Message sent', content: { "application/json": { schema: { $ref: "#/components/schemas/Message" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
||||
/* #swagger.responses[429] = { description: 'Too many messages (rate limited)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[502] = { description: 'Mail delivery failed', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
contactLimiter,
|
||||
body('message').isString().trim().notEmpty().isLength({ max: 5000 }),
|
||||
body('email').optional({ values: 'falsy' }).isEmail(),
|
||||
@@ -22,12 +44,62 @@ publicRouter.post(
|
||||
)
|
||||
|
||||
// Content — gated by site mode (admins with a valid token bypass for preview).
|
||||
publicRouter.get('/posts/:category', siteMode, ctrl.getPosts)
|
||||
publicRouter.get('/posts/:category/:idOrSlug', siteMode, ctrl.getPost)
|
||||
publicRouter.get('/wiki', siteMode, ctrl.getWikiList)
|
||||
publicRouter.get(
|
||||
'/posts/:category',
|
||||
// #swagger.tags = ['Public']
|
||||
// #swagger.summary = 'List published posts in a category'
|
||||
// #swagger.description = 'Gated by site mode: during maintenance only admins with a valid session see content.'
|
||||
// #swagger.parameters['category'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'news | five-on-friday | newsletter | screenshots' }
|
||||
/* #swagger.responses[200] = { description: 'Published posts', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/Post" } } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Unknown category', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
siteMode,
|
||||
ctrl.getPosts,
|
||||
)
|
||||
publicRouter.get(
|
||||
'/posts/:category/:idOrSlug',
|
||||
// #swagger.tags = ['Public']
|
||||
// #swagger.summary = 'Get a single published post'
|
||||
// #swagger.parameters['category'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Post category.' }
|
||||
// #swagger.parameters['idOrSlug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Numeric id or slug.' }
|
||||
/* #swagger.responses[200] = { description: 'The post', content: { "application/json": { schema: { $ref: "#/components/schemas/Post" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Unknown category or post not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
siteMode,
|
||||
ctrl.getPost,
|
||||
)
|
||||
publicRouter.get(
|
||||
'/wiki',
|
||||
// #swagger.tags = ['Public']
|
||||
// #swagger.summary = 'List published wiki pages'
|
||||
/* #swagger.responses[200] = { description: 'Published wiki pages', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/WikiPage" } } } } } */
|
||||
siteMode,
|
||||
ctrl.getWikiList,
|
||||
)
|
||||
// Static paths must precede the :slug route so they aren't captured as a slug.
|
||||
publicRouter.get('/wiki/categories', siteMode, ctrl.getWikiCategories)
|
||||
publicRouter.get('/wiki/tags', siteMode, ctrl.getWikiTags)
|
||||
publicRouter.get('/wiki/:slug', siteMode, ctrl.getWikiPage)
|
||||
publicRouter.get(
|
||||
'/wiki/categories',
|
||||
// #swagger.tags = ['Public']
|
||||
// #swagger.summary = 'List wiki categories'
|
||||
/* #swagger.responses[200] = { description: 'Wiki categories', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/WikiCategory" } } } } } */
|
||||
siteMode,
|
||||
ctrl.getWikiCategories,
|
||||
)
|
||||
publicRouter.get(
|
||||
'/wiki/tags',
|
||||
// #swagger.tags = ['Public']
|
||||
// #swagger.summary = 'List wiki tags'
|
||||
/* #swagger.responses[200] = { description: 'Wiki tags', content: { "application/json": { schema: { type: "array", items: { type: "string" } } } } } */
|
||||
siteMode,
|
||||
ctrl.getWikiTags,
|
||||
)
|
||||
publicRouter.get(
|
||||
'/wiki/:slug',
|
||||
// #swagger.tags = ['Public']
|
||||
// #swagger.summary = 'Get a single published wiki page'
|
||||
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Wiki page slug.' }
|
||||
/* #swagger.responses[200] = { description: 'The wiki page', content: { "application/json": { schema: { $ref: "#/components/schemas/WikiPage" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
siteMode,
|
||||
ctrl.getWikiPage,
|
||||
)
|
||||
|
||||
module.exports = publicRouter
|
||||
|
||||
Reference in New Issue
Block a user