diff --git a/package-lock.json b/package-lock.json index 0858d69..ad9c721 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "MIT", "dependencies": { + "@asteasolutions/zod-to-openapi": "^8.5.0", "@emotion/css": "^11.13.5", "@sentry/cloudflare": "^10.46.0", "algoliasearch": "^5.50.0", @@ -238,6 +239,18 @@ "node": ">= 14.0.0" } }, + "node_modules/@asteasolutions/zod-to-openapi": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@asteasolutions/zod-to-openapi/-/zod-to-openapi-8.5.0.tgz", + "integrity": "sha512-SABbKiObg5dLRiTFnqiW1WWwGcg1BJfmHtT2asIBnBHg6Smy/Ms2KHc650+JI4Hw7lSkdiNebEGXpwoxfben8Q==", + "license": "MIT", + "dependencies": { + "openapi3-ts": "^4.1.2" + }, + "peerDependencies": { + "zod": "^4.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -4397,6 +4410,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/openapi3-ts": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/openapi3-ts/-/openapi3-ts-4.5.0.tgz", + "integrity": "sha512-jaL+HgTq2Gj5jRcfdutgRGLosCy/hT8sQf6VOy+P+g36cZOjI1iukdPnijC+4CmeRzg/jEllJUboEic2FhxhtQ==", + "license": "MIT", + "dependencies": { + "yaml": "^2.8.0" + } + }, "node_modules/optionator": { "version": "0.9.3", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", @@ -5451,7 +5473,6 @@ "version": "2.8.2", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", - "dev": true, "license": "ISC", "bin": { "yaml": "bin.mjs" @@ -5654,6 +5675,14 @@ "@algolia/client-common": "5.50.0" } }, + "@asteasolutions/zod-to-openapi": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@asteasolutions/zod-to-openapi/-/zod-to-openapi-8.5.0.tgz", + "integrity": "sha512-SABbKiObg5dLRiTFnqiW1WWwGcg1BJfmHtT2asIBnBHg6Smy/Ms2KHc650+JI4Hw7lSkdiNebEGXpwoxfben8Q==", + "requires": { + "openapi3-ts": "^4.1.2" + } + }, "@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -8081,6 +8110,14 @@ "mimic-function": "^5.0.0" } }, + "openapi3-ts": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/openapi3-ts/-/openapi3-ts-4.5.0.tgz", + "integrity": "sha512-jaL+HgTq2Gj5jRcfdutgRGLosCy/hT8sQf6VOy+P+g36cZOjI1iukdPnijC+4CmeRzg/jEllJUboEic2FhxhtQ==", + "requires": { + "yaml": "^2.8.0" + } + }, "optionator": { "version": "0.9.3", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", @@ -8698,8 +8735,7 @@ "yaml": { "version": "2.8.2", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", - "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", - "dev": true + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==" }, "yocto-queue": { "version": "0.1.0", diff --git a/package.json b/package.json index ed98901..e61e119 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "node": ">=24.11.0" }, "dependencies": { + "@asteasolutions/zod-to-openapi": "^8.5.0", "@emotion/css": "^11.13.5", "@sentry/cloudflare": "^10.46.0", "algoliasearch": "^5.50.0", diff --git a/src/index.ts b/src/index.ts index e68da7e..b837ba0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,9 @@ import { Hono } from 'hono'; import { cors } from 'hono/cors'; import { logger } from 'hono/logger'; +import './utils/openapi.ts'; + +import apiRoutes from './routes/api.ts'; import errorRoutes from './routes/errors.ts'; import indexRoutes from './routes/index.ts'; import librariesRoutes from './routes/libraries.ts'; @@ -19,6 +22,7 @@ app.use('*', cors(corsOptions)); // Load the routes indexRoutes(app); +apiRoutes(app); statsRoutes(app); whitelistRoutes(app); libraryRoutes(app); diff --git a/src/routes/api.schema.ts b/src/routes/api.schema.ts new file mode 100644 index 0000000..d9a9963 --- /dev/null +++ b/src/routes/api.schema.ts @@ -0,0 +1,29 @@ +import * as z from 'zod'; + +export const openApiResponseSchema = z.object({ + openapi: z.string(), + info: z.object({ + title: z.string(), + description: z.string().optional(), + version: z.string(), + }), + servers: z + .array( + z.object({ + url: z.string(), + description: z.string().optional(), + }), + ) + .optional(), + paths: z.record(z.string(), z.unknown()).openapi({ type: 'object' }), + components: z + .object({ + schemas: z + .record(z.string(), z.unknown()) + .openapi({ type: 'object' }) + .optional(), + }) + .optional(), +}); + +export type OpenApiResponse = z.infer; diff --git a/src/routes/api.spec.ts b/src/routes/api.spec.ts new file mode 100644 index 0000000..6ff4a71 --- /dev/null +++ b/src/routes/api.spec.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; + +import testCors from '../utils/spec/cors.ts'; +import { beforeRequest, request } from '../utils/spec/request.ts'; + +import type { OpenApiResponse } from './api.schema.ts'; + +describe('/api', () => { + // Fetch the endpoint + const path = '/api'; + const response = beforeRequest(path); + + // Test the endpoint + testCors(path, response); + it('returns the correct Cache headers', () => { + expect(response.headers.get('Cache-Control')).to.eq( + 'public, max-age=21600', + ); // 6 hours + }); + it('returns the correct status code', () => { + expect(response.status).to.eq(200); + }); + it('returns a valid OpenAPI spec in JSON format', async () => { + expect(response.headers.get('Content-Type')).to.match( + /application\/json/, + ); + const data = await response.json(); + expect(data.openapi).to.eq('3.0.0'); + expect(data.info.title).to.eq('cdnjs API'); + expect(data.paths['/libraries']).to.be.an('object'); + expect(data.paths['/libraries/{library}']).to.be.an('object'); + expect(data.components?.schemas?.Error).to.be.an('object'); + }); + + // Test with a trailing slash + it('responds to requests with a trailing slash', async () => { + const res = await request(path + '/'); + expect(res.status).to.eq(200); + const resData = await res.json(); + const responseData = await response.json(); + expect(resData).to.deep.eq(responseData); + }); +}); diff --git a/src/routes/api.ts b/src/routes/api.ts new file mode 100644 index 0000000..beccad1 --- /dev/null +++ b/src/routes/api.ts @@ -0,0 +1,310 @@ +import { + OpenAPIRegistry, + 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({ + 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 ( + openApiSpec.components?.parameters && + Object.keys(openApiSpec.components.parameters).length === 0 +) { + delete openApiSpec.components.parameters; +} + +/** + * 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, openApiSpec); +}; + +/** + * Register api route. + * + * @param app App instance. + */ +export default (app: Hono) => { + app.get('/api', handleGetApi); + app.get('/api/', handleGetApi); +}; diff --git a/src/utils/openapi.ts b/src/utils/openapi.ts new file mode 100644 index 0000000..bf22551 --- /dev/null +++ b/src/utils/openapi.ts @@ -0,0 +1,4 @@ +import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi'; +import { z } from 'zod'; + +extendZodWithOpenApi(z);