From c3803b6e85ca8cb172a98d00be521872091d9033 Mon Sep 17 00:00:00 2001 From: MattIPv4 Date: Fri, 1 May 2026 13:37:28 +0100 Subject: [PATCH 1/4] Pass OpenAPIRegistry to all route registrations --- eslint.config.js | 9 +++++++++ src/index.ts | 17 ++++++++++------- src/routes/api.ts | 3 ++- src/routes/errors.ts | 4 +++- src/routes/index.ts | 4 +++- src/routes/libraries.ts | 4 +++- src/routes/library.ts | 4 +++- src/routes/stats.ts | 4 +++- src/routes/whitelist.ts | 4 +++- 9 files changed, 39 insertions(+), 14 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index a7d9361..5bed943 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -14,6 +14,15 @@ export default defineConfig( name: 'prettier/config', ...prettier, }, + { + name: 'custom/typescript', + rules: { + '@typescript-eslint/no-unused-vars': [ + 'error', + { argsIgnorePattern: '^_' }, + ], + }, + }, { name: 'custom/jsdoc', rules: { diff --git a/src/index.ts b/src/index.ts index b837ba0..b9b35f4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,8 @@ import { logger } from 'hono/logger'; import './utils/openapi.ts'; +import { OpenAPIRegistry } from '@asteasolutions/zod-to-openapi'; + import apiRoutes from './routes/api.ts'; import errorRoutes from './routes/errors.ts'; import indexRoutes from './routes/index.ts'; @@ -17,17 +19,18 @@ import corsOptions from './utils/cors.ts'; // Create the base app const app = new Hono(); +const registry = new OpenAPIRegistry(); if (!env.DISABLE_LOGGING) app.use('*', logger()); app.use('*', cors(corsOptions)); // Load the routes -indexRoutes(app); -apiRoutes(app); -statsRoutes(app); -whitelistRoutes(app); -libraryRoutes(app); -librariesRoutes(app); -errorRoutes(app); +indexRoutes(app, registry); +apiRoutes(app, registry); +statsRoutes(app, registry); +whitelistRoutes(app, registry); +libraryRoutes(app, registry); +librariesRoutes(app, registry); +errorRoutes(app, registry); // Let's go! export default Sentry.withSentry( diff --git a/src/routes/api.ts b/src/routes/api.ts index beccad1..3167610 100644 --- a/src/routes/api.ts +++ b/src/routes/api.ts @@ -303,8 +303,9 @@ const handleGetApi = (ctx: Context) => { * Register api route. * * @param app App instance. + * @param _registry OpenAPI registry instance. */ -export default (app: Hono) => { +export default (app: Hono, _registry: OpenAPIRegistry) => { app.get('/api', handleGetApi); app.get('/api/', handleGetApi); }; diff --git a/src/routes/errors.ts b/src/routes/errors.ts index 622e441..ec28077 100644 --- a/src/routes/errors.ts +++ b/src/routes/errors.ts @@ -1,3 +1,4 @@ +import type { OpenAPIRegistry } from '@asteasolutions/zod-to-openapi'; import * as Sentry from '@sentry/cloudflare'; import type { Hono } from 'hono'; @@ -13,8 +14,9 @@ const stringOrUndefined = (value: unknown) => * Register error handlers for routes. * * @param app App instance. + * @param _registry OpenAPI registry instance. */ -export default (app: Hono) => { +export default (app: Hono, _registry: OpenAPIRegistry) => { // Pass request context to Sentry app.use('*', async (ctx, next) => { Sentry.setTags({ diff --git a/src/routes/index.ts b/src/routes/index.ts index b439978..422cb0b 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -1,3 +1,4 @@ +import type { OpenAPIRegistry } from '@asteasolutions/zod-to-openapi'; import { env } from 'cloudflare:workers'; import type { Context, Hono } from 'hono'; @@ -53,8 +54,9 @@ const handleGetRobotsTxt = (ctx: Context) => { * Register core routes. * * @param app App instance. + * @param _registry OpenAPI registry instance. */ -export default (app: Hono) => { +export default (app: Hono, _registry: OpenAPIRegistry) => { // Redirect root the API docs app.get('/', handleGet); diff --git a/src/routes/libraries.ts b/src/routes/libraries.ts index ba22e02..a4fc494 100644 --- a/src/routes/libraries.ts +++ b/src/routes/libraries.ts @@ -1,3 +1,4 @@ +import type { OpenAPIRegistry } from '@asteasolutions/zod-to-openapi'; import type { Context, Hono } from 'hono'; import { libraries } from '../utils/algolia.ts'; @@ -57,8 +58,9 @@ const handleGetLibraries = async (ctx: Context) => { * Register libraries routes. * * @param app App instance. + * @param _registry OpenAPI registry instance. */ -export default (app: Hono) => { +export default (app: Hono, _registry: OpenAPIRegistry) => { app.get('/libraries', handleGetLibraries); app.get('/libraries/', handleGetLibraries); }; diff --git a/src/routes/library.ts b/src/routes/library.ts index bd7049b..8262410 100644 --- a/src/routes/library.ts +++ b/src/routes/library.ts @@ -1,3 +1,4 @@ +import type { OpenAPIRegistry } from '@asteasolutions/zod-to-openapi'; import type { Context, Hono } from 'hono'; import files from '../utils/files.ts'; @@ -242,8 +243,9 @@ const handleGetLibrary = async (ctx: Context) => { * Register library routes. * * @param app App instance. + * @param _registry OpenAPI registry instance. */ -export default (app: Hono) => { +export default (app: Hono, _registry: OpenAPIRegistry) => { // Library version app.get('/libraries/:library/:version', handleGetLibraryVersion); app.get('/libraries/:library/:version/', handleGetLibraryVersion); diff --git a/src/routes/stats.ts b/src/routes/stats.ts index 2869a5c..b569168 100644 --- a/src/routes/stats.ts +++ b/src/routes/stats.ts @@ -1,3 +1,4 @@ +import type { OpenAPIRegistry } from '@asteasolutions/zod-to-openapi'; import type { Context, Hono } from 'hono'; import filter from '../utils/filter.ts'; @@ -32,8 +33,9 @@ const handleGetStats = async (ctx: Context) => { * Register stats routes. * * @param app App instance. + * @param _registry OpenAPI registry instance. */ -export default (app: Hono) => { +export default (app: Hono, _registry: OpenAPIRegistry) => { app.get('/stats', handleGetStats); app.get('/stats/', handleGetStats); }; diff --git a/src/routes/whitelist.ts b/src/routes/whitelist.ts index 39b8c2d..5040586 100644 --- a/src/routes/whitelist.ts +++ b/src/routes/whitelist.ts @@ -1,3 +1,4 @@ +import type { OpenAPIRegistry } from '@asteasolutions/zod-to-openapi'; import type { Context, Hono } from 'hono'; import files from '../utils/files.ts'; @@ -33,8 +34,9 @@ const handleGetWhitelist = (ctx: Context) => { * Register whitelist routes. * * @param app App instance. + * @param _registry OpenAPI registry instance. */ -export default (app: Hono) => { +export default (app: Hono, _registry: OpenAPIRegistry) => { // Whitelist app.get('/whitelist', handleGetWhitelist); app.get('/whitelist/', handleGetWhitelist); From 451eceb4c23937234a0f6cef8c21e29afd67813d Mon Sep 17 00:00:00 2001 From: MattIPv4 Date: Fri, 1 May 2026 14:00:26 +0100 Subject: [PATCH 2/4] Move OpenAPI registrations to route files --- src/routes/api.ts | 279 +++--------------------------------- src/routes/errors.schema.ts | 14 +- src/routes/libraries.ts | 49 ++++++- src/routes/library.ts | 97 ++++++++++++- src/routes/stats.ts | 34 ++++- src/routes/whitelist.ts | 37 ++++- 6 files changed, 231 insertions(+), 279 deletions(-) diff --git a/src/routes/api.ts b/src/routes/api.ts index 3167610..9e50e05 100644 --- a/src/routes/api.ts +++ b/src/routes/api.ts @@ -3,270 +3,13 @@ import { OpenApiGeneratorV3, } from '@asteasolutions/zod-to-openapi'; import type { Context, Hono } from 'hono'; -import { z } from 'zod'; import respond, { withCache } from '../utils/respond.ts'; import { type OpenApiResponse, openApiResponseSchema } from './api.schema.ts'; -import { errorResponseSchema } from './errors.schema.ts'; -import { librariesResponseSchema } from './libraries.schema.ts'; -import { - libraryResponseSchema, - libraryVersionResponseSchema, -} from './library.schema.ts'; -import { statsResponseSchema } from './stats.schema.ts'; -import { whitelistResponseSchema } from './whitelist.schema.ts'; const registry = new OpenAPIRegistry(); -// Register the Error schema as a component -registry.register('Error', errorResponseSchema); - -const errorResponseRef = { - $ref: '#/components/schemas/Error', -} as const; - -const errorResponseContent = { - 'application/json': { - schema: errorResponseRef, - }, -} as const; - -const humanOutputQuery = { - output: z.string().optional().openapi({ - description: - 'Use the output value human to receive the JSON results in pretty print format, presented on a HTML page.', - }), -}; - -registry.registerPath({ - method: 'get', - path: '/api', - summary: 'Get OpenAPI Specification', - description: 'Returns the OpenAPI specification for the cdnjs API.', - tags: ['meta'], - request: { - query: z.object({ - ...humanOutputQuery, - }), - }, - responses: { - 200: { - description: 'OpenAPI JSON Specification', - content: { - 'application/json': { - schema: openApiResponseSchema, - }, - }, - }, - 500: { - description: 'Internal server error', - content: errorResponseContent, - }, - }, -}); - -registry.registerPath({ - method: 'get', - path: '/libraries', - summary: 'Browsing all libraries on cdnjs', - description: - 'The `/libraries` endpoint will return a JSON object with three top-level properties.\n\nThis API endpoint can also be used to search cdnjs for libraries, by making use of the optional `search` URL query parameter.\n\nThe cache lifetime on this endpoint is six hours.', - tags: ['libraries'], - request: { - query: z.object({ - search: z.string().optional().openapi({ - description: - "The value to use when searching the libraries index on cdnjs.\n\nLibraries will not be ranked by search relevance when they are returned, they will be ranked using the same ranking as when no search query is provided.\n\n*This ranking is done by Algolia and is primarily based on the number of stars each library's associated GitHub repo has.*", - }), - fields: z.string().optional().openapi({ - description: - 'Provide a comma-separated string of fields to return in each library object from the cdnjs Algolia index. name and latest will always be present in every object. Any field requested that does not exist will be included in each object with a null value. Currently, the following fields (case-sensitive) are published in the Algolia index for each library and can be requested via this parameter: filename, description, version, keywords, alternativeNames, fileType, github, objectID, license, homepage, repository, author, originalName, sri. The available fields are based on the SearchEntry structure in our tools repo.', - }), - search_fields: z.string().optional().openapi({ - description: - 'Provide a comma-separated string of fields to be considered when searching for a given search query parameter. Not all fields are supported for this, any unsupported fields given will be silently ignored. Currently, the following fields (case-sensitive) are supported: name, alternativeNames, github.repo, description, keywords, filename, repositories.url, github.user, maintainers.name. The supported fields are controlled by our Algolia settings and are mirrored in the API server libraries route logic.', - }), - limit: z.number().optional().openapi({ - description: - 'Limit the number of library objects that are returned in the results array. This value will be reflected in the total top-level property, but the available property will return the full number with no limit applied.', - }), - ...humanOutputQuery, - }), - }, - responses: { - 200: { - description: 'A list of libraries', - content: { - 'application/json': { - schema: librariesResponseSchema, - }, - }, - }, - 500: { - description: 'Internal server error', - content: errorResponseContent, - }, - }, -}); - -registry.registerPath({ - method: 'get', - path: '/libraries/{library}', - summary: 'Getting a specific library on cdnjs', - description: - 'Accessing `assets` for all versions of a library using this endpoint is deprecated. The `assets` property now only contains a single entry for the latest version. To access the assets of any version, use the `/libraries/:library/:version` endpoint.\n\nSee [cdnjs/cdnjs issue #14140](https://github.com/cdnjs/cdnjs/issues/14140) for more information.\n\nThe `/libraries/:library` endpoint allows for data on a specific library to be requested and will return a JSON object with all library data properties by default.\n\nThe cache lifetime on this endpoint is six hours.', - tags: ['libraries'], - request: { - params: z.object({ - library: z - .string() - .openapi({ description: 'The name of the library.' }), - }), - query: z.object({ - fields: z.string().optional().openapi({ - description: - 'Provide a comma-separated string of fields to return in the library object.', - }), - ...humanOutputQuery, - }), - }, - responses: { - 200: { - description: 'Library details', - content: { - 'application/json': { - schema: libraryResponseSchema, - }, - }, - }, - 404: { - description: 'Library not found', - content: { - 'application/json': { - schema: errorResponseRef, - }, - }, - }, - 500: { - description: 'Internal server error', - content: errorResponseContent, - }, - }, -}); - -registry.registerPath({ - method: 'get', - path: '/libraries/{library}/{version}', - summary: 'Getting a specific version for a library on cdnjs', - description: - 'The `/libraries/:library/:version` endpoint returns a JSON object with details specific to a requested version of a library on cdnjs.\n\nThe cache lifetime on this endpoint is 355 days, identical to the CDN. The response is also marked as immutable, as a version on cdnjs will never change once published.\n\ncdnjs only allows access to specific versions of a library, and these are considered immutable. Access to tags for a library, such as `latest`, is not supported as these have a mutable definition, which would go against what cdnjs aims to provide with long-life caching on responses and SRI hashes.', - tags: ['libraries'], - request: { - params: z.object({ - library: z - .string() - .openapi({ description: 'The name of the library.' }), - version: z - .string() - .openapi({ description: 'The version of the library.' }), - }), - query: z.object({ - fields: z.string().optional().openapi({ - description: - 'Provide a comma-separated string of fields to return.', - }), - ...humanOutputQuery, - }), - }, - responses: { - 200: { - description: 'Library version details', - content: { - 'application/json': { - schema: libraryVersionResponseSchema, - }, - }, - }, - 404: { - description: 'Library or version not found', - content: { - 'application/json': { - schema: errorResponseRef, - }, - }, - }, - 500: { - description: 'Internal server error', - content: errorResponseContent, - }, - }, -}); - -registry.registerPath({ - method: 'get', - path: '/whitelist', - summary: 'Fetch details about the cdnjs file extension whitelist', - description: - 'The `/whitelist` endpoint returns a JSON object containing a list of extensions permitted on the CDN as well as categories for those extensions.\n\nThe cache lifetime on this endpoint is 6 hours.', - tags: ['meta'], - request: { - query: z.object({ - fields: z.string().optional().openapi({ - description: - 'Provide a comma-separated string of fields to return from the available whitelist data.', - }), - ...humanOutputQuery, - }), - }, - responses: { - 200: { - description: 'Whitelist details', - content: { - 'application/json': { - schema: whitelistResponseSchema, - }, - }, - }, - 500: { - description: 'Internal server error', - content: errorResponseContent, - }, - }, -}); - -registry.registerPath({ - method: 'get', - path: '/stats', - summary: 'Fetch basic statistics for cdnjs', - description: - 'The `/stats` endpoint returns a JSON object containing a set of statistics relating to cdnjs.\n\nThe cache lifetime on this endpoint is 6 hours.', - tags: ['meta'], - request: { - query: z.object({ - fields: z.string().optional().openapi({ - description: - 'Provide a comma-separated string of fields to return in the stats object.', - }), - ...humanOutputQuery, - }), - }, - responses: { - 200: { - description: 'Statistics', - content: { - 'application/json': { - schema: statsResponseSchema, - }, - }, - }, - 500: { - description: 'Internal server error', - content: errorResponseContent, - }, - }, -}); - const generator = new OpenApiGeneratorV3(registry.definitions); const openApiSpec = generator.generateDocument({ @@ -303,9 +46,27 @@ const handleGetApi = (ctx: Context) => { * Register api route. * * @param app App instance. - * @param _registry OpenAPI registry instance. + * @param registry OpenAPI registry instance. */ -export default (app: Hono, _registry: OpenAPIRegistry) => { +export default (app: Hono, registry: OpenAPIRegistry) => { app.get('/api', handleGetApi); app.get('/api/', handleGetApi); + + registry.registerPath({ + method: 'get', + path: '/api', + summary: 'Get OpenAPI Specification', + description: 'Returns the OpenAPI specification for the cdnjs API.', + tags: ['meta'], + responses: { + 200: { + description: 'OpenAPI JSON Specification', + content: { + 'application/json': { + schema: openApiResponseSchema, + }, + }, + }, + }, + }); }; diff --git a/src/routes/errors.schema.ts b/src/routes/errors.schema.ts index db053f8..717d73a 100644 --- a/src/routes/errors.schema.ts +++ b/src/routes/errors.schema.ts @@ -1,10 +1,12 @@ import * as z from 'zod'; -export const errorResponseSchema = z.object({ - error: z.literal(true), - status: z.number(), - message: z.string(), - ref: z.string().optional(), -}); +export const errorResponseSchema = z + .object({ + error: z.literal(true), + status: z.number(), + message: z.string(), + ref: z.string().optional(), + }) + .openapi('Error'); export type ErrorResponse = z.infer; diff --git a/src/routes/libraries.ts b/src/routes/libraries.ts index a4fc494..0445f50 100644 --- a/src/routes/libraries.ts +++ b/src/routes/libraries.ts @@ -1,12 +1,16 @@ import type { OpenAPIRegistry } from '@asteasolutions/zod-to-openapi'; import type { Context, Hono } from 'hono'; +import * as z from 'zod'; import { libraries } from '../utils/algolia.ts'; import filter from '../utils/filter.ts'; import { queryArray, queryCheck } from '../utils/query.ts'; import respond, { withCache } from '../utils/respond.ts'; -import type { LibrariesResponse } from './libraries.schema.ts'; +import { + type LibrariesResponse, + librariesResponseSchema, +} from './libraries.schema.ts'; /** * Handle GET /libraries requests. @@ -58,9 +62,48 @@ const handleGetLibraries = async (ctx: Context) => { * Register libraries routes. * * @param app App instance. - * @param _registry OpenAPI registry instance. + * @param registry OpenAPI registry instance. */ -export default (app: Hono, _registry: OpenAPIRegistry) => { +export default (app: Hono, registry: OpenAPIRegistry) => { app.get('/libraries', handleGetLibraries); app.get('/libraries/', handleGetLibraries); + + registry.registerPath({ + method: 'get', + path: '/libraries', + summary: 'Browsing all libraries on cdnjs', + description: + 'The `/libraries` endpoint will return a JSON object with three top-level properties.\n\nThis API endpoint can also be used to search cdnjs for libraries, by making use of the optional `search` URL query parameter.\n\nThe cache lifetime on this endpoint is six hours.', + tags: ['libraries'], + request: { + query: z.object({ + search: z.string().optional().openapi({ + description: + "The value to use when searching the libraries index on cdnjs.\n\nLibraries will not be ranked by search relevance when they are returned, they will be ranked using the same ranking as when no search query is provided.\n\n*This ranking is done by Algolia and is primarily based on the number of stars each library's associated GitHub repo has.*", + }), + fields: z.string().optional().openapi({ + description: + 'Provide a comma-separated string of fields to return in each library object from the cdnjs Algolia index. name and latest will always be present in every object. Any field requested that does not exist will be included in each object with a null value. Currently, the following fields (case-sensitive) are published in the Algolia index for each library and can be requested via this parameter: filename, description, version, keywords, alternativeNames, fileType, github, objectID, license, homepage, repository, author, originalName, sri. The available fields are based on the SearchEntry structure in our tools repo.', + }), + search_fields: z.string().optional().openapi({ + description: + 'Provide a comma-separated string of fields to be considered when searching for a given search query parameter. Not all fields are supported for this, any unsupported fields given will be silently ignored. Currently, the following fields (case-sensitive) are supported: name, alternativeNames, github.repo, description, keywords, filename, repositories.url, github.user, maintainers.name. The supported fields are controlled by our Algolia settings and are mirrored in the API server libraries route logic.', + }), + limit: z.number().optional().openapi({ + description: + 'Limit the number of library objects that are returned in the results array. This value will be reflected in the total top-level property, but the available property will return the full number with no limit applied.', + }), + }), + }, + responses: { + 200: { + description: 'A list of libraries', + content: { + 'application/json': { + schema: librariesResponseSchema, + }, + }, + }, + }, + }); }; diff --git a/src/routes/library.ts b/src/routes/library.ts index 8262410..b5857c4 100644 --- a/src/routes/library.ts +++ b/src/routes/library.ts @@ -1,5 +1,6 @@ import type { OpenAPIRegistry } from '@asteasolutions/zod-to-openapi'; import type { Context, Hono } from 'hono'; +import * as z from 'zod'; import files from '../utils/files.ts'; import filter from '../utils/filter.ts'; @@ -12,9 +13,12 @@ import { import { queryCheck } from '../utils/query.ts'; import respond, { notFound, withCache } from '../utils/respond.ts'; -import type { - LibraryResponse, - LibraryVersionResponse, +import { errorResponseSchema } from './errors.schema.ts'; +import { + type LibraryResponse, + type LibraryVersionResponse, + libraryResponseSchema, + libraryVersionResponseSchema, } from './library.schema.ts'; /** @@ -243,14 +247,97 @@ const handleGetLibrary = async (ctx: Context) => { * Register library routes. * * @param app App instance. - * @param _registry OpenAPI registry instance. + * @param registry OpenAPI registry instance. */ -export default (app: Hono, _registry: OpenAPIRegistry) => { +export default (app: Hono, registry: OpenAPIRegistry) => { // Library version app.get('/libraries/:library/:version', handleGetLibraryVersion); app.get('/libraries/:library/:version/', handleGetLibraryVersion); + registry.registerPath({ + method: 'get', + path: '/libraries/{library}/{version}', + summary: 'Getting a specific version for a library on cdnjs', + description: + 'The `/libraries/:library/:version` endpoint returns a JSON object with details specific to a requested version of a library on cdnjs.\n\nThe cache lifetime on this endpoint is 355 days, identical to the CDN. The response is also marked as immutable, as a version on cdnjs will never change once published.\n\ncdnjs only allows access to specific versions of a library, and these are considered immutable. Access to tags for a library, such as `latest`, is not supported as these have a mutable definition, which would go against what cdnjs aims to provide with long-life caching on responses and SRI hashes.', + tags: ['libraries'], + request: { + params: z.object({ + library: z + .string() + .openapi({ description: 'The name of the library.' }), + version: z + .string() + .openapi({ description: 'The version of the library.' }), + }), + query: z.object({ + fields: z.string().optional().openapi({ + description: + 'Provide a comma-separated string of fields to return.', + }), + }), + }, + responses: { + 200: { + description: 'Library version details', + content: { + 'application/json': { + schema: libraryVersionResponseSchema, + }, + }, + }, + 404: { + description: 'Library or version not found', + content: { + 'application/json': { + schema: errorResponseSchema, + }, + }, + }, + }, + }); + // Library app.get('/libraries/:library', handleGetLibrary); app.get('/libraries/:library/', handleGetLibrary); + + registry.registerPath({ + method: 'get', + path: '/libraries/{library}', + summary: 'Getting a specific library on cdnjs', + description: + 'Accessing `assets` for all versions of a library using this endpoint is deprecated. The `assets` property now only contains a single entry for the latest version. To access the assets of any version, use the `/libraries/:library/:version` endpoint.\n\nSee [cdnjs/cdnjs issue #14140](https://github.com/cdnjs/cdnjs/issues/14140) for more information.\n\nThe `/libraries/:library` endpoint allows for data on a specific library to be requested and will return a JSON object with all library data properties by default.\n\nThe cache lifetime on this endpoint is six hours.', + tags: ['libraries'], + request: { + params: z.object({ + library: z + .string() + .openapi({ description: 'The name of the library.' }), + }), + query: z.object({ + fields: z.string().optional().openapi({ + description: + 'Provide a comma-separated string of fields to return in the library object.', + }), + }), + }, + responses: { + 200: { + description: 'Library details', + content: { + 'application/json': { + schema: libraryResponseSchema, + }, + }, + }, + 404: { + description: 'Library not found', + content: { + 'application/json': { + schema: errorResponseSchema, + }, + }, + }, + }, + }); }; diff --git a/src/routes/stats.ts b/src/routes/stats.ts index b569168..60a481b 100644 --- a/src/routes/stats.ts +++ b/src/routes/stats.ts @@ -1,12 +1,13 @@ import type { OpenAPIRegistry } from '@asteasolutions/zod-to-openapi'; import type { Context, Hono } from 'hono'; +import * as z from 'zod'; import filter from '../utils/filter.ts'; import { libraries } from '../utils/metadata.ts'; import { queryCheck } from '../utils/query.ts'; import respond, { withCache } from '../utils/respond.ts'; -import type { StatsResponse } from './stats.schema.ts'; +import { type StatsResponse, statsResponseSchema } from './stats.schema.ts'; /** * Handle GET /stats requests. @@ -33,9 +34,36 @@ const handleGetStats = async (ctx: Context) => { * Register stats routes. * * @param app App instance. - * @param _registry OpenAPI registry instance. + * @param registry OpenAPI registry instance. */ -export default (app: Hono, _registry: OpenAPIRegistry) => { +export default (app: Hono, registry: OpenAPIRegistry) => { app.get('/stats', handleGetStats); app.get('/stats/', handleGetStats); + + registry.registerPath({ + method: 'get', + path: '/stats', + summary: 'Fetch basic statistics for cdnjs', + description: + 'The `/stats` endpoint returns a JSON object containing a set of statistics relating to cdnjs.\n\nThe cache lifetime on this endpoint is 6 hours.', + tags: ['meta'], + request: { + query: z.object({ + fields: z.string().optional().openapi({ + description: + 'Provide a comma-separated string of fields to return in the stats object.', + }), + }), + }, + responses: { + 200: { + description: 'Statistics', + content: { + 'application/json': { + schema: statsResponseSchema, + }, + }, + }, + }, + }); }; diff --git a/src/routes/whitelist.ts b/src/routes/whitelist.ts index 5040586..97cbccc 100644 --- a/src/routes/whitelist.ts +++ b/src/routes/whitelist.ts @@ -1,12 +1,16 @@ import type { OpenAPIRegistry } from '@asteasolutions/zod-to-openapi'; import type { Context, Hono } from 'hono'; +import * as z from 'zod'; import files from '../utils/files.ts'; import filter from '../utils/filter.ts'; import { queryCheck } from '../utils/query.ts'; import respond, { withCache } from '../utils/respond.ts'; -import type { WhitelistResponse } from './whitelist.schema.ts'; +import { + type WhitelistResponse, + whitelistResponseSchema, +} from './whitelist.schema.ts'; /** * Handle GET /whitelist requests. @@ -34,10 +38,37 @@ const handleGetWhitelist = (ctx: Context) => { * Register whitelist routes. * * @param app App instance. - * @param _registry OpenAPI registry instance. + * @param registry OpenAPI registry instance. */ -export default (app: Hono, _registry: OpenAPIRegistry) => { +export default (app: Hono, registry: OpenAPIRegistry) => { // Whitelist app.get('/whitelist', handleGetWhitelist); app.get('/whitelist/', handleGetWhitelist); + + registry.registerPath({ + method: 'get', + path: '/whitelist', + summary: 'Fetch details about the cdnjs file extension whitelist', + description: + 'The `/whitelist` endpoint returns a JSON object containing a list of extensions permitted on the CDN as well as categories for those extensions.\n\nThe cache lifetime on this endpoint is 6 hours.', + tags: ['meta'], + request: { + query: z.object({ + fields: z.string().optional().openapi({ + description: + 'Provide a comma-separated string of fields to return from the available whitelist data.', + }), + }), + }, + responses: { + 200: { + description: 'Whitelist details', + content: { + 'application/json': { + schema: whitelistResponseSchema, + }, + }, + }, + }, + }); }; From 648871583d3745db8338c66fb7dd779cb16370a5 Mon Sep 17 00:00:00 2001 From: MattIPv4 Date: Fri, 1 May 2026 14:01:51 +0100 Subject: [PATCH 3/4] Generate OpenAPI response from new registry --- src/routes/api.ts | 104 +++++++++++++++++++++++++++++++++------------- 1 file changed, 75 insertions(+), 29 deletions(-) diff --git a/src/routes/api.ts b/src/routes/api.ts index 9e50e05..2a45b79 100644 --- a/src/routes/api.ts +++ b/src/routes/api.ts @@ -3,43 +3,89 @@ import { OpenApiGeneratorV3, } from '@asteasolutions/zod-to-openapi'; import type { Context, Hono } from 'hono'; +import * as z from 'zod'; import respond, { withCache } from '../utils/respond.ts'; import { type OpenApiResponse, openApiResponseSchema } from './api.schema.ts'; +import { errorResponseSchema } from './errors.schema.ts'; -const registry = new OpenAPIRegistry(); +const createHandleGetApi = (registry: OpenAPIRegistry) => { + let spec: ReturnType; -const generator = new OpenApiGeneratorV3(registry.definitions); + const getOrGenerateSpec = () => { + if (!spec) { + registry.definitions.forEach((def) => { + if (def.type === 'route') { + def.route.request ??= {}; + def.route.request.query ??= z.object({}); -const openApiSpec = generator.generateDocument({ - openapi: '3.0.0', - info: { - title: 'cdnjs API', - description: - 'The cdnjs API allows for easy programmatic navigation of our libraries.', - version: '1.0.0', - }, - servers: [{ url: 'https://api.cdnjs.com', description: 'Production' }], -}); + if (!(def.route.request.query instanceof z.ZodObject)) { + throw new Error( + `Expected query schema for ${def.route.method.toUpperCase()} ${def.route.path} to be a ZodObject`, + ); + } -if ( - openApiSpec.components?.parameters && - Object.keys(openApiSpec.components.parameters).length === 0 -) { - delete openApiSpec.components.parameters; -} + // Inject the human output query parameter that all routes support + def.route.request.query = def.route.request.query.extend({ + output: z.string().optional().openapi({ + description: + 'Use the output value human to receive the JSON results in pretty print format, presented on a HTML page.', + }), + }); -/** - * Handle GET /api requests. - * - * @param ctx Request context. - */ -const handleGetApi = (ctx: Context) => { - // Set a 6 hour life on this response - withCache(ctx, 6 * 60 * 60); + // Inject the standard 500 response that all routes could return + def.route.responses[500] = { + description: 'Internal server error', + content: { + 'application/json': { + schema: errorResponseSchema, + }, + }, + }; + } + }); + + spec = new OpenApiGeneratorV3( + registry.definitions, + ).generateDocument({ + openapi: '3.0.0', + info: { + title: 'cdnjs API', + description: + 'The cdnjs API allows for easy programmatic navigation of our libraries.', + version: '1.0.0', + }, + servers: [ + { url: 'https://api.cdnjs.com', description: 'Production' }, + ], + }); + + // Clean up the empty parameters components object, as the OpenAPI linter doesn't like it + if ( + spec.components?.parameters && + Object.keys(spec.components.parameters).length === 0 + ) { + delete spec.components.parameters; + } + } + + return spec; + }; + + /** + * Handle GET /api requests. + * + * @param ctx Request context. + */ + const handleGetApi = (ctx: Context) => { + // Set a 6 hour life on this response + withCache(ctx, 6 * 60 * 60); + + return respond(ctx, getOrGenerateSpec()); + }; - return respond(ctx, openApiSpec); + return handleGetApi; }; /** @@ -49,8 +95,8 @@ const handleGetApi = (ctx: Context) => { * @param registry OpenAPI registry instance. */ export default (app: Hono, registry: OpenAPIRegistry) => { - app.get('/api', handleGetApi); - app.get('/api/', handleGetApi); + app.get('/api', createHandleGetApi(registry)); + app.get('/api/', createHandleGetApi(registry)); registry.registerPath({ method: 'get', From e41a6026f45cfc60d01ae4d81da083cfd1f244cd Mon Sep 17 00:00:00 2001 From: MattIPv4 Date: Fri, 1 May 2026 14:08:05 +0100 Subject: [PATCH 4/4] Sort OpenAPI routes consistently --- src/routes/api.ts | 39 +++++++++++++++++++++++++++++++++++---- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/src/routes/api.ts b/src/routes/api.ts index 2a45b79..13bd965 100644 --- a/src/routes/api.ts +++ b/src/routes/api.ts @@ -15,7 +15,9 @@ const createHandleGetApi = (registry: OpenAPIRegistry) => { const getOrGenerateSpec = () => { if (!spec) { - registry.definitions.forEach((def) => { + const definitions = registry.definitions.slice(); + + definitions.forEach((def) => { if (def.type === 'route') { def.route.request ??= {}; def.route.request.query ??= z.object({}); @@ -46,9 +48,38 @@ const createHandleGetApi = (registry: OpenAPIRegistry) => { } }); - spec = new OpenApiGeneratorV3( - registry.definitions, - ).generateDocument({ + definitions.sort((a, b) => { + if (a.type === 'route' && b.type === 'route') { + // Sort by method first + const methodOrder = [ + 'get', + 'post', + 'patch', + 'put', + 'delete', + ]; + const methodComparison = + methodOrder.indexOf(a.route.method) - + methodOrder.indexOf(b.route.method); + if (methodComparison !== 0) { + return methodComparison; + } + + // Then, sort by how deep the route is + const aDepth = a.route.path.split('/').length; + const bDepth = b.route.path.split('/').length; + if (aDepth !== bDepth) { + return aDepth - bDepth; + } + + // Finally, sort alphabetically + return a.route.path.localeCompare(b.route.path); + } + + return 0; + }); + + spec = new OpenApiGeneratorV3(definitions).generateDocument({ openapi: '3.0.0', info: { title: 'cdnjs API',