diff --git a/ghost/api-framework/.oxfmtrc.json b/ghost/api-framework/.oxfmtrc.json new file mode 100644 index 00000000000..99c61b470e8 --- /dev/null +++ b/ghost/api-framework/.oxfmtrc.json @@ -0,0 +1,13 @@ +{ + "singleQuote": true, + "ignorePatterns": [ + "**/node_modules/**", + "**/build/**", + "**/coverage/**", + "**/cjs/**", + "**/es/**", + "**/types/**", + "**/*.hbs", + "**/test/fixtures/**" + ] +} diff --git a/ghost/api-framework/.oxlintrc.json b/ghost/api-framework/.oxlintrc.json new file mode 100644 index 00000000000..1fb62e042d9 --- /dev/null +++ b/ghost/api-framework/.oxlintrc.json @@ -0,0 +1,12 @@ +{ + "plugins": ["node", "typescript"], + "ignorePatterns": [ + "**/node_modules/**", + "**/build/**", + "**/coverage/**", + "**/cjs/**", + "**/es/**", + "**/types/**", + "**/test/fixtures/**" + ] +} diff --git a/ghost/api-framework/README.md b/ghost/api-framework/README.md new file mode 100644 index 00000000000..99767311c5a --- /dev/null +++ b/ghost/api-framework/README.md @@ -0,0 +1,150 @@ +# API Framework + +API framework used by Ghost + +## Purpose + +Composable framework for Ghost API controllers, request framing, validation/serialization pipelines, and HTTP response helpers. + +## Usage + +### Stages + +Each request goes through the following stages: + +- input validation +- input serialisation +- permissions +- query +- output serialisation + +The framework we are building pipes a request through these stages in respect of the API controller configuration. + +### Frame + +Is a class, which holds all the information for request processing. We pass this instance by reference. +Each function can modify the original instance. No need to return the class instance. + +#### Structure + +``` +{ + original: Object, + options: Object, + data: Object, + user: Object, + file: Object, + files: Array +} +``` + +#### Example + +``` +{ + original: { + include: 'tags' + }, + options: { + withRelated: ['tags'] + }, + data: { + posts: [] + } +} +``` + +### API Controller + +A controller is no longer just a function, it's a set of configurations. + +#### Structure + +``` +edit: function || object +``` + +``` +edit: { + headers: object, + options: Array, + data: Array, + validation: object | function, + permissions: boolean | object | function, + query: function +} +``` + +#### Examples + +``` +edit: { + headers: { + cacheInvalidate: true + }, + // Allowed url/query params + options: ['include'] + // Url/query param validation configuration + validation: { + options: { + include: { + required: true, + values: ['tags'] + } + } + }, + permissions: true, + // Returns a model response! + query(frame) { + return models.Post.edit(frame.data, frame.options); + } +} +``` + +``` +read: { + // Allowed url/query params, which will be remembered inside `frame.data` + // This is helpful for READ requests e.g. `model.findOne(frame.data, frame.options)`. + // Our model layer requires sending the where clauses as first parameter. + data: ['slug'] + validation: { + data: { + slug: { + values: ['eins'] + } + } + }, + permissions: true, + query(frame) { + return models.Post.findOne(frame.data, frame.options); + } +} +``` + +``` +edit: { + validation() { + // custom validation, skip framework + }, + permissions: { + unsafeAttrs: ['author'] + }, + query(frame) { + return models.Post.edit(frame.data, frame.options); + } +} +``` + +## Develop + +This is a monorepo package. + +Follow the instructions for the top-level repo. + +1. `git clone` this repo & `cd` into it as usual +2. Run `pnpm install` to install top-level dependencies. + +## Test + +- `pnpm lint` runs oxlint +- `pnpm test` runs lint and tests diff --git a/ghost/api-framework/index.js b/ghost/api-framework/index.js new file mode 100644 index 00000000000..2fd7fa32f49 --- /dev/null +++ b/ghost/api-framework/index.js @@ -0,0 +1,27 @@ +/** @typedef {object} PermissionsObject */ +/** @typedef {boolean} PermissionsBoolean */ + +/** @typedef {number} StatusCodeNumber */ +/** @typedef {(result: any) => number} StatusCodeFunction */ + +/** @typedef {object} ValidationObject */ + +/** + * @typedef {object} ControllerMethod + * @property {object} headers + * @property {PermissionsBoolean | PermissionsObject} permissions + * @property {string[]} [options] + * @property {ValidationObject} [validation] + * @property {string[]} [data] + * @property {StatusCodeFunction | StatusCodeNumber} [statusCode] + * @property {object} [response] + * @property {function} [cache] + * @property {(frame: import('./lib/Frame')) => object} [generateCacheKeyData] + * @property {(frame: import('./lib/Frame')) => any} query + */ + +/** + * @typedef {Record & Record<'docName', string>} Controller + */ + +module.exports = require('./lib/api-framework'); diff --git a/ghost/api-framework/lib/Frame.js b/ghost/api-framework/lib/Frame.js new file mode 100644 index 00000000000..70d265bb8a1 --- /dev/null +++ b/ghost/api-framework/lib/Frame.js @@ -0,0 +1,110 @@ +const debug = require('@tryghost/debug')('frame'); +const _ = require('lodash'); + +/** + * @description The "frame" holds all information of a request. + * + * Each party can modify the frame by reference. + * A request hits a lot of stages in the API implementation and that's why modification by reference was the + * easiest to use. We always have access to the original input, we never loose track of it. + */ +class Frame { + #headers = {}; + constructor(obj = {}) { + this.original = obj; + + /** + * options: Query params, url params, context and custom options + * data: Body or if the ctrl wants query/url params inside body + * user: Logged in user + * file: Uploaded file + * files: Uploaded files + * apiType: Content or admin api access + * docName: The endpoint name, e.g. "posts" + * method: The method name, e.g. "browse" + */ + this.options = {}; + this.data = {}; + this.user = {}; + this.file = {}; + this.files = []; + this.#headers = {}; + this.apiType = null; + this.docName = null; + this.method = null; + this.response = null; + } + + /** + * @description Configure the frame. + * + * If you instantiate a new frame, all the data you pass in, land in `this.original`. This is helpful + * for debugging to see what the original input was. + * + * This function will prepare the incoming data for further processing. + * Based on the API ctrl implemented, this fn will pick allowed properties to either options or data. + */ + configure(apiConfig) { + debug('configure'); + + if (apiConfig.options) { + if (typeof apiConfig.options === 'function') { + apiConfig.options = apiConfig.options(this); + } + + if (Object.prototype.hasOwnProperty.call(this.original, 'query')) { + Object.assign(this.options, _.pick(this.original.query, apiConfig.options)); + } + + if (Object.prototype.hasOwnProperty.call(this.original, 'params')) { + Object.assign(this.options, _.pick(this.original.params, apiConfig.options)); + } + + if (Object.prototype.hasOwnProperty.call(this.original, 'options')) { + Object.assign(this.options, _.pick(this.original.options, apiConfig.options)); + } + } + + this.options.context = this.original.context; + + if (this.original.body && Object.keys(this.original.body).length) { + this.data = _.cloneDeep(this.original.body); + } else { + if (apiConfig.data) { + if (typeof apiConfig.data === 'function') { + apiConfig.data = apiConfig.data(this); + } + + if (Object.prototype.hasOwnProperty.call(this.original, 'query')) { + Object.assign(this.data, _.pick(this.original.query, apiConfig.data)); + } + + if (Object.prototype.hasOwnProperty.call(this.original, 'params')) { + Object.assign(this.data, _.pick(this.original.params, apiConfig.data)); + } + + if (Object.prototype.hasOwnProperty.call(this.original, 'options')) { + Object.assign(this.data, _.pick(this.original.options, apiConfig.data)); + } + } + } + + this.user = this.original.user; + this.file = this.original.file; + this.files = this.original.files; + + debug('original', this.original); + debug('options', this.options); + debug('data', this.data); + } + + setHeader(header, value) { + this.#headers[header] = value; + } + + getHeaders() { + return Object.assign({}, this.#headers); + } +} + +module.exports = Frame; diff --git a/ghost/api-framework/lib/api-framework.js b/ghost/api-framework/lib/api-framework.js new file mode 100644 index 00000000000..f0845664c30 --- /dev/null +++ b/ghost/api-framework/lib/api-framework.js @@ -0,0 +1,29 @@ +module.exports = { + get headers() { + return require('./headers'); + }, + + get http() { + return require('./http'); + }, + + get Frame() { + return require('./Frame'); + }, + + get pipeline() { + return require('./pipeline'); + }, + + get validators() { + return require('./validators'); + }, + + get serializers() { + return require('./serializers'); + }, + + get utils() { + return require('./utils'); + }, +}; diff --git a/ghost/api-framework/lib/headers.js b/ghost/api-framework/lib/headers.js new file mode 100644 index 00000000000..66ee43c0485 --- /dev/null +++ b/ghost/api-framework/lib/headers.js @@ -0,0 +1,167 @@ +const url = require('url'); +const debug = require('@tryghost/debug')('headers'); +const INVALIDATE_ALL = '/*'; + +const cacheInvalidate = (result, options = {}) => { + let value = options.value; + + return { + 'X-Cache-Invalidate': value || INVALIDATE_ALL, + }; +}; + +const disposition = { + /** + * @description Generate CSV header. + * + * @param {Object} result - API response + * @param {Object} options + * @return {Object} + */ + csv(result, options = {}) { + let value = options.value; + + if (typeof options.value === 'function') { + value = options.value(); + } + + return { + 'Content-Disposition': `Attachment; filename="${value}"`, + 'Content-Type': 'text/csv', + }; + }, + + /** + * @description Generate JSON header. + * + * @param {Object} result - API response + * @param {Object} options + * @return {Object} + */ + json(result, options = {}) { + return { + 'Content-Disposition': `Attachment; filename="${options.value}"`, + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(JSON.stringify(result)), + }; + }, + + /** + * @description Generate YAML header. + * + * @param {Object} result - API response + * @param {Object} options + * @return {Object} + */ + yaml(result, options = {}) { + return { + 'Content-Disposition': `Attachment; filename="${options.value}"`, + 'Content-Type': 'application/yaml', + 'Content-Length': Buffer.byteLength(JSON.stringify(result)), + }; + }, + + /** + * @description Content Disposition Header + * + * Create a header that invokes the 'Save As' dialog in the browser when exporting the database to file. The 'filename' + * parameter is governed by [RFC6266](http://tools.ietf.org/html/rfc6266#section-4.3). + * + * For encoding whitespace and non-ISO-8859-1 characters, you MUST use the "filename*=" attribute, NOT "filename=". + * Ideally, both. Examples: http://tools.ietf.org/html/rfc6266#section-5 + * + * We'll use ISO-8859-1 characters here to keep it simple. + * + * @see http://tools.ietf.org/html/rfc598 + */ + file(result, options = {}) { + return Promise.resolve() + .then(() => { + let value = options.value; + + if (typeof options.value === 'function') { + value = options.value(); + } + + return value; + }) + .then((filename) => { + return { + 'Content-Disposition': `Attachment; filename="${filename}"`, + }; + }); + }, +}; + +module.exports = { + /** + * @description Get header based on ctrl configuration. + * + * @param {Object} result - API response + * @param {Object} apiConfigHeaders + * @param {import('@tryghost/api-framework').Frame} frame + * @return {Promise} + */ + async get(result, apiConfigHeaders = {}, frame) { + let headers = {}; + + if (apiConfigHeaders.disposition) { + const dispositionHeader = await disposition[apiConfigHeaders.disposition.type]( + result, + apiConfigHeaders.disposition, + ); + + if (dispositionHeader) { + Object.assign(headers, dispositionHeader); + } + } + + if (apiConfigHeaders.cacheInvalidate) { + const cacheInvalidationHeader = cacheInvalidate( + result, + apiConfigHeaders.cacheInvalidate, + ); + + if (cacheInvalidationHeader) { + Object.assign(headers, cacheInvalidationHeader); + } + } + + const locationHeaderDisabled = apiConfigHeaders?.location === false; + const hasLocationResolver = apiConfigHeaders?.location?.resolve; + const hasFrameData = + (frame?.method === 'add' || hasLocationResolver) && result[frame.docName]?.[0]?.id; + + if (!locationHeaderDisabled && hasFrameData) { + const protocol = frame.original.url.secure === false ? 'http://' : 'https://'; + const resourceId = result[frame.docName][0].id; + + let locationURL = url.resolve( + `${protocol}${frame.original.url.host}`, + frame.original.url.pathname, + ); + if (!locationURL.endsWith('/')) { + locationURL += '/'; + } + + locationURL += `${resourceId}/`; + + if (hasLocationResolver) { + locationURL = apiConfigHeaders.location.resolve(locationURL); + } + + const locationHeader = { + Location: locationURL, + }; + + Object.assign(headers, locationHeader); + } + + const headersFromFrame = frame.getHeaders(); + + Object.assign(headers, headersFromFrame); + + debug(headers); + return headers; + }, +}; diff --git a/ghost/api-framework/lib/http.js b/ghost/api-framework/lib/http.js new file mode 100644 index 00000000000..7d65ac61815 --- /dev/null +++ b/ghost/api-framework/lib/http.js @@ -0,0 +1,134 @@ +const url = require('url'); +const debug = require('@tryghost/debug')('http'); + +const Frame = require('./Frame'); +const headers = require('./headers'); + +/** + * @description HTTP wrapper. + * + * This wrapper is used in the routes definition (see web/). + * The wrapper receives the express request, prepares the frame and forwards the request to the pipeline. + * + * @param {import('@tryghost/api-framework').Controller} apiImpl - Pipeline wrapper, which executes the target ctrl function. + * @return {import('express').RequestHandler} + */ +const http = (apiImpl) => { + /** + * @param {import('express').Request} req - Express request object. + * @param {import('express').Response} res - Express response object. + * @param {import('express').NextFunction} next - Express next function. + * @returns {Promise} + */ + return async function Http(req, res, next) { + debug(`External API request to ${req.url}`); + let apiKey = null; + let integration = null; + let user = null; + + if (req.api_key) { + apiKey = { + id: req.api_key.get('id'), + type: req.api_key.get('type'), + }; + integration = { + id: req.api_key.get('integration_id'), + }; + } + + if (req.user?.id) { + user = req.user.id; + } + + const frame = new Frame({ + body: req.body, + file: req.file, + files: req.files, + query: req.query, + params: req.params, + user: req.user, + session: req.session, + url: { + host: req.vhost ? req.vhost.host : req.get('host'), + pathname: url.parse(req.originalUrl || req.url).pathname, + secure: req.secure, + }, + context: { + api_key: apiKey, + user: user, + integration: integration, + member: req.member || null, + }, + }); + + frame.configure({ + options: apiImpl.options, + data: apiImpl.data, + }); + + try { + const result = await apiImpl(frame); + + debug(`External API request to ${frame.docName}.${frame.method}`); + + // CASE: api ctrl wants to handle the express response (e.g. streams) + if (typeof result === 'function') { + debug('ctrl function call'); + return result(req, res, next); + } + + let statusCode = 200; + if (typeof apiImpl.statusCode === 'function') { + statusCode = apiImpl.statusCode(result); + } else if (apiImpl.statusCode) { + statusCode = apiImpl.statusCode; + } + + res.status(statusCode); + + // CASE: generate headers based on the api ctrl configuration + const apiHeaders = (await headers.get(result, apiImpl.headers, frame)) || {}; + res.set(apiHeaders); + + const send = (format) => { + if (format === 'plain') { + debug('plain text response'); + return res.send(result); + } + + debug('json response'); + res.json(result || {}); + }; + + let responseFormat; + + if (apiImpl.response) { + if (typeof apiImpl.response.format === 'function') { + const apiResponseFormat = apiImpl.response.format(); + + if (apiResponseFormat.then) { + // is promise + return apiResponseFormat.then((formatName) => { + send(formatName); + }); + } else { + responseFormat = apiResponseFormat; + } + } else { + responseFormat = apiImpl.response.format; + } + } + + send(responseFormat); + } catch (err) { + req.frameOptions = { + docName: frame.docName, + method: frame.method, + }; + + next(err); + } + }; +}; + +module.exports = http; diff --git a/ghost/api-framework/lib/pipeline.js b/ghost/api-framework/lib/pipeline.js new file mode 100644 index 00000000000..186b644f141 --- /dev/null +++ b/ghost/api-framework/lib/pipeline.js @@ -0,0 +1,302 @@ +const debug = require('@tryghost/debug')('pipeline'); +const _ = require('lodash'); +const errors = require('@tryghost/errors'); +const { sequence } = require('@tryghost/promise'); + +const Frame = require('./Frame'); +const serializers = require('./serializers'); +const validators = require('./validators'); + +// Replacer for JSON.stringify that returns every plain object with its keys +// sorted, so the serialized output is deterministic at every depth. Unlike an +// array replacer — which acts as a recursive key whitelist and silently drops +// any key not present in the top-level list — this preserves all nested keys. +function sortKeysReplacer(_key, value) { + if (value && typeof value === 'object' && !Array.isArray(value)) { + const sorted = {}; + for (const k of Object.keys(value).sort()) { + sorted[k] = value[k]; + } + return sorted; + } + return value; +} + +const STAGES = { + validation: { + /** + * @description Input validation. + * + * We call the shared validator which runs the request through: + * + * 1. Shared validator + * 2. Custom API validators + * + * @param {Object} apiUtils - Local utils of target API version. + * @param {Object} apiConfig - Docname & Method of ctrl. + * @param {import('@tryghost/api-framework').ControllerMethod} apiImpl - Controller configuration. + * @param {import('@tryghost/api-framework').Frame} frame + * @return {Promise} + */ + input(apiUtils, apiConfig, apiImpl, frame) { + debug('stages: validation'); + const tasks = []; + + // CASE: do validation completely yourself + if (typeof apiImpl.validation === 'function') { + debug('validation function call'); + return apiImpl.validation(frame); + } + + tasks.push(function doValidation() { + return validators.handle.input( + Object.assign({}, apiConfig, apiImpl.validation), + apiUtils.validators.input, + frame, + ); + }); + + return sequence(tasks); + }, + }, + + serialisation: { + /** + * @description Input Serialisation. + * + * We call the shared serializer which runs the request through: + * + * 1. Shared serializers + * 2. Custom API serializers + * + * @param {Object} apiUtils - Local utils of target API version. + * @param {Object} apiConfig - Docname & Method of ctrl. + * @param {import('@tryghost/api-framework').ControllerMethod} apiImpl - Controller configuration. + * @param {import('@tryghost/api-framework').Frame} frame + * @return {Promise} + */ + input(apiUtils, apiConfig, apiImpl, frame) { + debug('stages: input serialisation'); + return serializers.handle.input( + Object.assign({ data: apiImpl.data }, apiConfig), + apiUtils.serializers.input, + frame, + ); + }, + + /** + * @description Output Serialisation. + * + * We call the shared serializer which runs the request through: + * + * 1. Shared serializers + * 2. Custom API serializers + * + * @param {Object} apiUtils - Local utils of target API version. + * @param {Object} apiConfig - Docname & Method of ctrl. + * @param {import('@tryghost/api-framework').ControllerMethod} apiImpl - Controller configuration. + * @param {import('@tryghost/api-framework').Frame} frame + * @return {Promise} + */ + output(response, apiUtils, apiConfig, apiImpl, frame) { + debug('stages: output serialisation'); + return serializers.handle.output( + response, + apiConfig, + apiUtils.serializers.output, + frame, + ); + }, + }, + + /** + * @description Permissions stage. + * + * We call the target API implementation of permissions. + * Permissions implementation can change across API versions. + * There is no shared implementation right now. + * + * @param {Object} apiUtils - Local utils of target API version. + * @param {Object} apiConfig - Docname & Method of ctrl. + * @param {import('@tryghost/api-framework').ControllerMethod} apiImpl - Controller configuration. + * @param {import('@tryghost/api-framework').Frame} frame + * @return {Promise} + */ + permissions(apiUtils, apiConfig, apiImpl, frame) { + debug('stages: permissions'); + const tasks = []; + + // CASE: it's required to put the permission key to avoid security holes + if (!Object.prototype.hasOwnProperty.call(apiImpl, 'permissions')) { + return Promise.reject(new errors.IncorrectUsageError()); + } + + // CASE: handle permissions completely yourself + if (typeof apiImpl.permissions === 'function') { + debug('permissions function call'); + return apiImpl.permissions(frame); + } + + // CASE: skip stage completely + if (apiImpl.permissions === false) { + debug('disabled permissions'); + return Promise.resolve(); + } + + if (typeof apiImpl.permissions === 'object' && apiImpl.permissions.before) { + tasks.push(function beforePermissions() { + return apiImpl.permissions.before(frame); + }); + } + + tasks.push(function doPermissions() { + return apiUtils.permissions.handle( + Object.assign({}, apiConfig, apiImpl.permissions), + frame, + ); + }); + + return sequence(tasks); + }, + + /** + * @description Execute controller & receive model response. + * + * @param {Object} apiUtils - Local utils of target API version. + * @param {Object} apiConfig - Docname & Method of ctrl. + * @param {import('@tryghost/api-framework').ControllerMethod} apiImpl - Controller configuration. + * @param {import('@tryghost/api-framework').Frame} frame + * @return {Promise} + */ + query(apiUtils, apiConfig, apiImpl, frame) { + debug('stages: query'); + + if (!apiImpl.query) { + return Promise.reject(new errors.IncorrectUsageError()); + } + + return apiImpl.query(frame); + }, +}; + +const controllerMap = new Map(); + +/** + * @description The pipeline runs the request through all stages (validation, serialisation, permissions). + * + * The target API version calls the pipeline and wraps the actual ctrl implementation to be able to + * run the request through various stages before hitting the controller. + * + * The stages are executed in the following order: + * + * 1. Input validation - General & schema validation + * 2. Input serialisation - Modification of incoming data e.g. force filters, auto includes, url transformation etc. + * 3. Permissions - Runs after validation & serialisation because the body structure must be valid (see unsafeAttrs) + * 4. Controller - Execute the controller implementation & receive model response. + * 5. Output Serialisation - Output formatting, Deprecations, Extra attributes etc... + * + * @param {import('@tryghost/api-framework').Controller} apiController + * @param {Object} apiUtils - Local utils (validation & serialisation) from target API version + * @param {String} [apiType] - Content or Admin API access + * @return {Object} + */ +const pipeline = (apiController, apiUtils, apiType) => { + if (controllerMap.has(apiController)) { + return controllerMap.get(apiController); + } + + const keys = Object.keys(apiController).filter((key) => key !== 'docName'); + const docName = apiController.docName; + + // CASE: api controllers are objects with configuration. + // We have to ensure that we expose a functional interface e.g. `api.posts.add` has to be available. + const result = keys.reduce((obj, method) => { + const apiImpl = _.cloneDeep(apiController)[method]; + + Object.freeze(apiImpl.headers); + + obj[method] = async function ImplWrapper() { + const apiConfig = { docName, method }; + let options; + let data; + let frame; + + if (arguments.length === 2) { + data = arguments[0]; + options = arguments[1]; + } else if (arguments.length === 1) { + options = arguments[0] || {}; + } else { + options = {}; + } + + // CASE: http helper already creates it's own frame. + if (!(options instanceof Frame)) { + debug(`Internal API request for ${docName}.${method}`); + frame = new Frame({ + body: data, + options: _.omit(options, 'context'), + context: options.context || {}, + }); + + frame.configure({ + options: apiImpl.options, + data: apiImpl.data, + }); + } else { + frame = options; + } + + // CASE: api controller *can* be a single function, but it's not recommended to disable the framework. + if (typeof apiImpl === 'function') { + debug('ctrl function call'); + return apiImpl(frame); + } + + frame.apiType = apiType; + frame.docName = docName; + frame.method = method; + + let cacheKeyData = frame.options; + if (apiImpl.generateCacheKeyData) { + cacheKeyData = await apiImpl.generateCacheKeyData(frame); + } + + const cacheKey = JSON.stringify(cacheKeyData, sortKeysReplacer); + + if (apiImpl.cache) { + const response = await apiImpl.cache.get(cacheKey, getResponse); + if (response) { + return Promise.resolve(response); + } + } + + async function getResponse() { + await STAGES.validation.input(apiUtils, apiConfig, apiImpl, frame); + await STAGES.serialisation.input(apiUtils, apiConfig, apiImpl, frame); + await STAGES.permissions(apiUtils, apiConfig, apiImpl, frame); + const response = await STAGES.query(apiUtils, apiConfig, apiImpl, frame); + await STAGES.serialisation.output(response, apiUtils, apiConfig, apiImpl, frame); + return frame.response; + } + + const response = await getResponse(); + + if (apiImpl.cache) { + await apiImpl.cache.set(cacheKey, response); + } + + return response; + }; + + Object.assign(obj[method], apiImpl); + return obj; + }, {}); + + controllerMap.set(apiController, result); + + return result; +}; + +module.exports = pipeline; +module.exports.STAGES = STAGES; diff --git a/ghost/api-framework/lib/serializers/handle.js b/ghost/api-framework/lib/serializers/handle.js new file mode 100644 index 00000000000..94b8539968b --- /dev/null +++ b/ghost/api-framework/lib/serializers/handle.js @@ -0,0 +1,143 @@ +const debug = require('@tryghost/debug')('serializers:handle'); +const { sequence } = require('@tryghost/promise'); +const errors = require('@tryghost/errors'); + +/** + * @description Shared input serialization handler. + * + * The shared input handler runs the request through all the validation steps. + * + * 1. Shared serialization + * 2. API serialization + * + * @param {Object} apiConfig - Docname + method of the ctrl + * @param {Object} apiSerializers - Target API serializers + * @param {import('@tryghost/api-framework').Frame} frame + */ +module.exports.input = (apiConfig, apiSerializers, frame) => { + debug('input'); + + const tasks = []; + const sharedSerializers = require('./input'); + + if (!apiConfig) { + return Promise.reject(new errors.IncorrectUsageError()); + } + + if (!apiSerializers) { + return Promise.reject(new errors.IncorrectUsageError()); + } + + // ##### SHARED ALL SERIALIZATION + + tasks.push(function serializeAllShared() { + return sharedSerializers.all.all(apiConfig, frame); + }); + + if (sharedSerializers.all[apiConfig.method]) { + tasks.push(function serializeAllShared() { + return sharedSerializers.all[apiConfig.method](apiConfig, frame); + }); + } + + // ##### API VERSION RESOURCE SERIALIZATION + + if (apiSerializers.all) { + tasks.push(function serializeOptionsShared() { + return apiSerializers.all(apiConfig, frame); + }); + } + + if (apiSerializers[apiConfig.docName]) { + if (apiSerializers[apiConfig.docName].all) { + tasks.push(function serializeOptionsShared() { + return apiSerializers[apiConfig.docName].all(apiConfig, frame); + }); + } + + if (apiSerializers[apiConfig.docName][apiConfig.method]) { + tasks.push(function serializeOptionsShared() { + return apiSerializers[apiConfig.docName][apiConfig.method](apiConfig, frame); + }); + } + } + + debug(tasks); + return sequence(tasks); +}; + +const getBestMatchSerializer = function (apiSerializers, docName, method) { + if (apiSerializers[docName]?.[method]) { + debug(`Calling ${docName}.${method}`); + return apiSerializers[docName][method].bind(apiSerializers[docName]); + } else if (apiSerializers[docName]?.all) { + debug(`Calling ${docName}.all`); + return apiSerializers[docName].all.bind(apiSerializers[docName]); + } + + debug(`Returning as-is`); + return false; +}; + +/** + * @description Shared output serialization handler. + * + * The shared output handler runs the request through all the validation steps. + * + * 1. Shared serialization + * 2. API serialization + * + * @param {Object} response - API response + * @param {Object} apiConfig - Docname + method of the ctrl + * @param {Object} apiSerializers - Target API serializers + * @param {import('@tryghost/api-framework').Frame} frame + */ +module.exports.output = (response = {}, apiConfig, apiSerializers, frame) => { + debug('output'); + + const tasks = []; + + if (!apiConfig) { + return Promise.reject(new errors.IncorrectUsageError()); + } + + if (!apiSerializers) { + return Promise.reject(new errors.IncorrectUsageError()); + } + + // ##### API VERSION RESOURCE SERIALIZATION + + if (apiSerializers.all?.before) { + tasks.push(function allSerializeBefore() { + return apiSerializers.all.before(response, apiConfig, frame); + }); + } + + const customSerializer = getBestMatchSerializer( + apiSerializers, + apiConfig.docName, + apiConfig.method, + ); + const defaultSerializer = getBestMatchSerializer(apiSerializers, 'default', apiConfig.method); + + if (customSerializer) { + // CASE: custom serializer exists + tasks.push(function doCustomSerializer() { + return customSerializer(response, apiConfig, frame); + }); + } else if (defaultSerializer) { + // CASE: Fall back to default serializer + tasks.push(function doDefaultSerializer() { + return defaultSerializer(response, apiConfig, frame); + }); + } + + if (apiSerializers.all?.after) { + tasks.push(function allSerializeAfter() { + return apiSerializers.all.after(apiConfig, frame); + }); + } + + debug(tasks); + return sequence(tasks); +}; diff --git a/ghost/api-framework/lib/serializers/index.js b/ghost/api-framework/lib/serializers/index.js new file mode 100644 index 00000000000..8fed3415ab7 --- /dev/null +++ b/ghost/api-framework/lib/serializers/index.js @@ -0,0 +1,13 @@ +module.exports = { + get handle() { + return require('./handle'); + }, + + get input() { + return require('./input'); + }, + + get output() { + return require('./output'); + }, +}; diff --git a/ghost/api-framework/lib/serializers/input/all.js b/ghost/api-framework/lib/serializers/input/all.js new file mode 100644 index 00000000000..c4b347f0b13 --- /dev/null +++ b/ghost/api-framework/lib/serializers/input/all.js @@ -0,0 +1,41 @@ +const debug = require('@tryghost/debug')('serializers:input:all'); +const _ = require('lodash'); +const utils = require('../../utils'); + +const INTERNAL_OPTIONS = ['transacting', 'forUpdate']; + +/** + * @description Shared serializer for all requests. + * + * Transforms certain options from API notation into model readable language/notation. + * + * e.g. API uses "include", but model layer uses "withRelated". + */ +module.exports = { + all(apiConfig, frame) { + debug('serialize all'); + + if (frame.options.include) { + frame.options.withRelated = utils.options.trimAndLowerCase(frame.options.include); + delete frame.options.include; + } + + if (frame.options.fields) { + frame.options.columns = utils.options.trimAndLowerCase(frame.options.fields); + delete frame.options.fields; + } + + if (frame.options.formats) { + frame.options.formats = utils.options.trimAndLowerCase(frame.options.formats); + } + + if (frame.options.formats && frame.options.columns) { + frame.options.columns = frame.options.columns.concat(frame.options.formats); + } + + if (!frame.options.context.internal) { + debug('omit internal options'); + frame.options = _.omit(frame.options, INTERNAL_OPTIONS); + } + }, +}; diff --git a/ghost/api-framework/lib/serializers/input/index.js b/ghost/api-framework/lib/serializers/input/index.js new file mode 100644 index 00000000000..3a8190073bf --- /dev/null +++ b/ghost/api-framework/lib/serializers/input/index.js @@ -0,0 +1,5 @@ +module.exports = { + get all() { + return require('./all'); + }, +}; diff --git a/ghost/api-framework/lib/serializers/output/index.js b/ghost/api-framework/lib/serializers/output/index.js new file mode 100644 index 00000000000..f053ebf7976 --- /dev/null +++ b/ghost/api-framework/lib/serializers/output/index.js @@ -0,0 +1 @@ +module.exports = {}; diff --git a/ghost/api-framework/lib/utils/index.js b/ghost/api-framework/lib/utils/index.js new file mode 100644 index 00000000000..78db2d7fa91 --- /dev/null +++ b/ghost/api-framework/lib/utils/index.js @@ -0,0 +1,5 @@ +module.exports = { + get options() { + return require('./options'); + }, +}; diff --git a/ghost/api-framework/lib/utils/options.js b/ghost/api-framework/lib/utils/options.js new file mode 100644 index 00000000000..8badb97b14f --- /dev/null +++ b/ghost/api-framework/lib/utils/options.js @@ -0,0 +1,32 @@ +const _ = require('lodash'); +const { IncorrectUsageError } = require('@tryghost/errors'); + +/** + * @description Helper function to prepare params for internal usages. + * + * e.g. "a,B,c" -> ["a", "b", "c"] + * + * @param {String} params + * @return {Array} + */ +const trimAndLowerCase = (params) => { + params = params || ''; + + if (_.isString(params)) { + params = params.split(','); + } + + // If we don't have an array at this point, something is wrong, so we should throw an + // error to avoid trying to .map over something else + if (!_.isArray(params)) { + throw new IncorrectUsageError({ + message: 'Params must be a string or array', + }); + } + + return params.map((item) => { + return item.trim().toLowerCase(); + }); +}; + +module.exports.trimAndLowerCase = trimAndLowerCase; diff --git a/ghost/api-framework/lib/validators/handle.js b/ghost/api-framework/lib/validators/handle.js new file mode 100644 index 00000000000..5eb642be212 --- /dev/null +++ b/ghost/api-framework/lib/validators/handle.js @@ -0,0 +1,67 @@ +const debug = require('@tryghost/debug')('validators:handle'); +const errors = require('@tryghost/errors'); +const { sequence } = require('@tryghost/promise'); + +/** + * @description Shared input validation handler. + * + * The shared validation handler runs the request through all the validation steps. + * + * 1. Shared validation + * 2. API validation + * + * @param {Object} apiConfig - Docname + method of the ctrl + * @param {Object} apiValidators - Target API validators + * @param {import('@tryghost/api-framework').Frame} frame + */ +module.exports.input = (apiConfig, apiValidators, frame) => { + debug('input begin'); + + const tasks = []; + const sharedValidators = require('./input'); + + if (!apiValidators) { + return Promise.reject(new errors.IncorrectUsageError()); + } + + if (!apiConfig) { + return Promise.reject(new errors.IncorrectUsageError()); + } + + // ##### SHARED ALL VALIDATION + + tasks.push(function allShared() { + return sharedValidators.all.all(apiConfig, frame); + }); + + if (sharedValidators.all[apiConfig.method]) { + tasks.push(function allShared() { + return sharedValidators.all[apiConfig.method](apiConfig, frame); + }); + } + + // ##### API VERSION VALIDATION + + if (apiValidators.all) { + tasks.push(function allAPIVersion() { + return apiValidators.all[apiConfig.method](apiConfig, frame); + }); + } + + if (apiValidators[apiConfig.docName]) { + if (apiValidators[apiConfig.docName].all) { + tasks.push(function docNameAll() { + return apiValidators[apiConfig.docName].all(apiConfig, frame); + }); + } + + if (apiValidators[apiConfig.docName][apiConfig.method]) { + tasks.push(function docNameMethod() { + return apiValidators[apiConfig.docName][apiConfig.method](apiConfig, frame); + }); + } + } + + debug('input ready'); + return sequence(tasks); +}; diff --git a/ghost/api-framework/lib/validators/index.js b/ghost/api-framework/lib/validators/index.js new file mode 100644 index 00000000000..95c9b221dfa --- /dev/null +++ b/ghost/api-framework/lib/validators/index.js @@ -0,0 +1,9 @@ +module.exports = { + get handle() { + return require('./handle'); + }, + + get input() { + return require('./input'); + }, +}; diff --git a/ghost/api-framework/lib/validators/input/all.js b/ghost/api-framework/lib/validators/input/all.js new file mode 100644 index 00000000000..c27e77a70b0 --- /dev/null +++ b/ghost/api-framework/lib/validators/input/all.js @@ -0,0 +1,247 @@ +const debug = require('@tryghost/debug')('validators:input:all'); +const _ = require('lodash'); +const tpl = require('@tryghost/tpl'); +const { BadRequestError, ValidationError } = require('@tryghost/errors'); +const validator = require('@tryghost/validator'); + +const messages = { + validationFailed: 'Validation ({validationName}) failed for {key}', + noRootKeyProvided: "No root key ('{docName}') provided.", + invalidIdProvided: 'Invalid id provided.', +}; + +const GLOBAL_VALIDATORS = { + id: { matches: /^(?:[a-f\d]{24}|1|me)$/i }, + page: { matches: /^\d+$/ }, + limit: { matches: /^(?:\d+|all)$/ }, + from: { isDate: true }, + to: { isDate: true }, + columns: { matches: /^[\w, ]+$/ }, + order: { matches: /^[a-z0-9_,. ]+$/i }, + uuid: { isUUID: true }, + slug: { isSlug: true }, + name: {}, + email: { isEmail: true }, + filter: false, + context: false, + forUpdate: false, + transacting: false, + include: false, + formats: false, +}; + +const validate = (config, attrs) => { + let errors = []; + + _.each(config, (value, key) => { + if (value.required && !attrs[key]) { + errors.push( + new ValidationError({ + message: tpl(messages.validationFailed, { + validationName: 'FieldIsRequired', + key: key, + }), + }), + ); + } + }); + + _.each(attrs, (value, key) => { + debug(key, value); + + if (GLOBAL_VALIDATORS[key]) { + debug('global validation'); + errors = errors.concat(validator.validate(value, key, GLOBAL_VALIDATORS[key])); + } + + if (config?.[key]) { + const allowedValues = Array.isArray(config[key]) ? config[key] : config[key].values; + + if (allowedValues) { + debug('ctrl validation'); + + // CASE: we allow e.g. `formats=` + if (!value || !value.length) { + return; + } + + const valuesAsArray = Array.isArray(value) + ? value + : value.trim().toLowerCase().split(','); + const unallowedValues = _.filter(valuesAsArray, (valueToFilter) => { + return !allowedValues.includes(valueToFilter); + }); + + if (unallowedValues.length) { + // CASE: we do not error for invalid includes, just silently remove + if (key === 'include') { + attrs.include = valuesAsArray.filter((x) => allowedValues.includes(x)); + return; + } + + errors.push( + new ValidationError({ + message: tpl(messages.validationFailed, { + validationName: 'AllowedValues', + key: key, + }), + }), + ); + } + } + } + }); + + return errors; +}; + +module.exports = { + /** + * @param {object} apiConfig + * @param {import('@tryghost/api-framework').Frame} frame + */ + all(apiConfig, frame) { + debug('validate all'); + + let validationErrors = validate(apiConfig.options, frame.options); + + if (!_.isEmpty(validationErrors)) { + return Promise.reject(validationErrors[0]); + } + + return Promise.resolve(); + }, + + /** + * @param {object} apiConfig + * @param {import('@tryghost/api-framework').Frame} frame + */ + browse(apiConfig, frame) { + debug('validate browse'); + + let validationErrors = []; + + if (frame.data) { + validationErrors = validate(apiConfig.data, frame.data); + } + + if (!_.isEmpty(validationErrors)) { + return Promise.reject(validationErrors[0]); + } + }, + + read() { + debug('validate read'); + return this.browse(...arguments); + }, + + /** + * @param {object} apiConfig + * @param {import('@tryghost/api-framework').Frame} frame + */ + add(apiConfig, frame) { + debug('validate add'); + + // NOTE: this block should be removed completely once JSON Schema validations + // are introduced for all of the endpoints + if (!['posts', 'tags'].includes(apiConfig.docName)) { + if ( + _.isEmpty(frame.data) || + _.isEmpty(frame.data[apiConfig.docName]) || + _.isEmpty(frame.data[apiConfig.docName][0]) + ) { + return Promise.reject( + new BadRequestError({ + message: tpl(messages.noRootKeyProvided, { docName: apiConfig.docName }), + }), + ); + } + } + + if (apiConfig.data) { + const missedDataProperties = []; + const nilDataProperties = []; + + _.each(apiConfig.data, (value, key) => { + if (!Object.prototype.hasOwnProperty.call(frame.data[apiConfig.docName][0], key)) { + missedDataProperties.push(key); + } else if (_.isNil(frame.data[apiConfig.docName][0][key])) { + nilDataProperties.push(key); + } + }); + + if (missedDataProperties.length) { + return Promise.reject( + new ValidationError({ + message: tpl(messages.validationFailed, { + validationName: 'FieldIsRequired', + key: JSON.stringify(missedDataProperties), + }), + }), + ); + } + + if (nilDataProperties.length) { + return Promise.reject( + new ValidationError({ + message: tpl(messages.validationFailed, { + validationName: 'FieldIsInvalid', + key: JSON.stringify(nilDataProperties), + }), + }), + ); + } + } + }, + + /** + * @param {object} apiConfig + * @param {import('@tryghost/api-framework').Frame} frame + */ + edit(apiConfig, frame) { + debug('validate edit'); + const result = this.add(...arguments); + + if (result instanceof Promise) { + return result; + } + + // NOTE: this block should be removed completely once JSON Schema validations + // are introduced for all of the endpoints. `id` property is currently + // stripped from the request body and only the one provided in `options` + // is used in later logic + if (!['posts', 'tags'].includes(apiConfig.docName)) { + if ( + frame.options.id && + frame.data[apiConfig.docName][0].id && + frame.options.id !== frame.data[apiConfig.docName][0].id + ) { + return Promise.reject( + new BadRequestError({ + message: tpl(messages.invalidIdProvided), + }), + ); + } + } + }, + + changePassword() { + debug('validate changePassword'); + return this.add(...arguments); + }, + + resetPassword() { + debug('validate resetPassword'); + return this.add(...arguments); + }, + + setup() { + debug('validate setup'); + return this.add(...arguments); + }, + + publish() { + debug('validate schedule'); + return this.browse(...arguments); + }, +}; diff --git a/ghost/api-framework/lib/validators/input/index.js b/ghost/api-framework/lib/validators/input/index.js new file mode 100644 index 00000000000..3a8190073bf --- /dev/null +++ b/ghost/api-framework/lib/validators/input/index.js @@ -0,0 +1,5 @@ +module.exports = { + get all() { + return require('./all'); + }, +}; diff --git a/ghost/api-framework/package.json b/ghost/api-framework/package.json new file mode 100644 index 00000000000..cc428dd525c --- /dev/null +++ b/ghost/api-framework/package.json @@ -0,0 +1,29 @@ +{ + "name": "@tryghost/api-framework", + "version": "0.0.0", + "private": true, + "author": "Ghost Foundation", + "main": "index.js", + "scripts": { + "dev": "echo \"Implement me!\"", + "test:unit": "NODE_ENV=testing vitest run", + "test": "pnpm test:unit", + "lint": "oxlint -c .oxlintrc.json .", + "format": "oxfmt -c .oxfmtrc.json \"**/*.{js,ts,md}\"", + "format:check": "oxfmt -c .oxfmtrc.json --check \"**/*.{js,ts,md}\"" + }, + "dependencies": { + "@tryghost/debug": "catalog:", + "@tryghost/errors": "1.3.13", + "@tryghost/promise": "2.2.3", + "@tryghost/tpl": "2.2.3", + "@tryghost/validator": "0.2.22", + "lodash": "catalog:" + }, + "devDependencies": { + "oxfmt": "catalog:", + "oxlint": "catalog:", + "sinon": "catalog:", + "vitest": "catalog:" + } +} diff --git a/ghost/api-framework/test/api-framework.test.js b/ghost/api-framework/test/api-framework.test.js new file mode 100644 index 00000000000..8c7a9c908cd --- /dev/null +++ b/ghost/api-framework/test/api-framework.test.js @@ -0,0 +1,20 @@ +const assert = require('node:assert/strict'); + +describe('api-framework module exports', function () { + it('exposes all lazy getters', function () { + const apiFramework = require('../lib/api-framework'); + + assert.ok(apiFramework.headers); + assert.ok(apiFramework.http); + assert.ok(apiFramework.Frame); + assert.ok(apiFramework.pipeline); + assert.ok(apiFramework.validators); + assert.ok(apiFramework.serializers); + assert.ok(apiFramework.utils); + }); + + it('exposes serializer output module', function () { + const serializers = require('../lib/serializers'); + assert.deepEqual(serializers.output, {}); + }); +}); diff --git a/ghost/api-framework/test/frame.test.js b/ghost/api-framework/test/frame.test.js new file mode 100644 index 00000000000..0cc183a5bcb --- /dev/null +++ b/ghost/api-framework/test/frame.test.js @@ -0,0 +1,142 @@ +const assert = require('node:assert/strict'); +const shared = require('../'); + +describe('Frame', function () { + it('constructor', function () { + const frame = new shared.Frame(); + assert.deepEqual(Object.keys(frame), [ + 'original', + 'options', + 'data', + 'user', + 'file', + 'files', + 'apiType', + 'docName', + 'method', + 'response', + ]); + }); + + describe('fn: configure', function () { + it('no transform', function () { + const original = { + context: { user: 'id' }, + body: { posts: [] }, + params: { id: 'id' }, + query: { include: 'tags', filter: 'type:post', soup: 'yumyum' }, + }; + + const frame = new shared.Frame(original); + + frame.configure({}); + + assert.ok(frame.options.context.user); + assert.equal(frame.options.include, undefined); + assert.equal(frame.options.filter, undefined); + assert.equal(frame.options.id, undefined); + assert.equal(frame.options.soup, undefined); + + assert.ok(frame.data.posts); + }); + + it('transform with query', function () { + const original = { + context: { user: 'id' }, + body: { posts: [] }, + params: { id: 'id' }, + query: { include: 'tags', filter: 'type:post', soup: 'yumyum' }, + }; + + const frame = new shared.Frame(original); + + frame.configure({ + options: ['include', 'filter', 'id'], + }); + + assert.ok(frame.options.context.user); + assert.ok(frame.options.include); + assert.ok(frame.options.filter); + assert.ok(frame.options.id); + assert.equal(frame.options.soup, undefined); + + assert.ok(frame.data.posts); + }); + + it('transform', function () { + const original = { + context: { user: 'id' }, + options: { + slug: 'slug', + }, + }; + + const frame = new shared.Frame(original); + + frame.configure({ + options: ['include', 'filter', 'slug'], + }); + + assert.ok(frame.options.context.user); + assert.ok(frame.options.slug); + }); + + it('transform with data', function () { + const original = { + context: { user: 'id' }, + options: { + id: 'id', + }, + body: {}, + }; + + const frame = new shared.Frame(original); + + frame.configure({ + data: ['id'], + }); + + assert.ok(frame.options.context.user); + assert.equal(frame.options.id, undefined); + assert.ok(frame.data.id); + }); + + it('supports options/data selectors as functions', function () { + const original = { + context: { user: 'id' }, + query: { include: 'tags' }, + params: { slug: 'abc' }, + options: { id: 'id' }, + }; + + const frame = new shared.Frame(original); + + frame.configure({ + options() { + return ['include', 'slug']; + }, + data() { + return ['slug', 'id']; + }, + }); + + assert.equal(frame.options.include, 'tags'); + assert.equal(frame.options.slug, 'abc'); + assert.equal(frame.data.slug, 'abc'); + assert.equal(frame.data.id, 'id'); + }); + }); + + describe('headers', function () { + it('sets and returns copied headers', function () { + const frame = new shared.Frame(); + frame.setHeader('X-Test', '1'); + + const headers = frame.getHeaders(); + assert.deepEqual(headers, { 'X-Test': '1' }); + + headers['X-Test'] = '2'; + assert.deepEqual(frame.getHeaders(), { 'X-Test': '1' }); + }); + }); +}); diff --git a/ghost/api-framework/test/headers.test.js b/ghost/api-framework/test/headers.test.js new file mode 100644 index 00000000000..bf26dce27ba --- /dev/null +++ b/ghost/api-framework/test/headers.test.js @@ -0,0 +1,261 @@ +const assert = require('node:assert/strict'); +const shared = require('../'); +const Frame = require('../lib/Frame'); + +describe('Headers', function () { + it('empty headers config', function () { + return shared.headers.get({}, {}, new Frame()).then((result) => { + assert.deepEqual(result, {}); + }); + }); + + describe('config.disposition', function () { + it('json', function () { + return shared.headers + .get({}, { disposition: { type: 'json', value: 'value' } }, new Frame()) + .then((result) => { + assert.deepEqual(result, { + 'Content-Disposition': 'Attachment; filename="value"', + 'Content-Type': 'application/json', + 'Content-Length': 2, + }); + }); + }); + + it('csv', function () { + return shared.headers + .get({}, { disposition: { type: 'csv', value: 'my.csv' } }, new Frame()) + .then((result) => { + assert.deepEqual(result, { + 'Content-Disposition': 'Attachment; filename="my.csv"', + 'Content-Type': 'text/csv', + }); + }); + }); + + it('csv with function', async function () { + const result = await shared.headers.get( + {}, + { + disposition: { + type: 'csv', + value() { + // pretend we're doing some dynamic filename logic in this function + const filename = `awesome-data-2022-08-01.csv`; + return filename; + }, + }, + }, + new Frame(), + ); + assert.deepEqual(result, { + 'Content-Disposition': 'Attachment; filename="awesome-data-2022-08-01.csv"', + 'Content-Type': 'text/csv', + }); + }); + + it('file', async function () { + const result = await shared.headers.get( + {}, + { disposition: { type: 'file', value: 'my.txt' } }, + new Frame(), + ); + assert.deepEqual(result, { + 'Content-Disposition': 'Attachment; filename="my.txt"', + }); + }); + + it('file with function', async function () { + const result = await shared.headers.get( + {}, + { + disposition: { + type: 'file', + value() { + // pretend we're doing some dynamic filename logic in this function + const filename = `awesome-data-2022-08-01.txt`; + return filename; + }, + }, + }, + new Frame(), + ); + assert.deepEqual(result, { + 'Content-Disposition': 'Attachment; filename="awesome-data-2022-08-01.txt"', + }); + }); + + it('yaml', function () { + return shared.headers + .get('yaml file', { disposition: { type: 'yaml', value: 'my.yaml' } }, new Frame()) + .then((result) => { + assert.deepEqual(result, { + 'Content-Disposition': 'Attachment; filename="my.yaml"', + 'Content-Type': 'application/yaml', + 'Content-Length': 11, + }); + }); + }); + }); + + describe('config.cacheInvalidate', function () { + it('default', function () { + return shared.headers.get({}, { cacheInvalidate: true }, new Frame()).then((result) => { + assert.deepEqual(result, { + 'X-Cache-Invalidate': '/*', + }); + }); + }); + + it('custom value', function () { + return shared.headers + .get({}, { cacheInvalidate: { value: 'value' } }, new Frame()) + .then((result) => { + assert.deepEqual(result, { + 'X-Cache-Invalidate': 'value', + }); + }); + }); + }); + + describe('location header', function () { + it('adds header when all needed data is present and method is add', function () { + const apiResult = { + posts: [ + { + id: 'id_value', + }, + ], + }; + + const apiConfigHeaders = {}; + const frame = new Frame(); + frame.docName = 'posts'; + frame.method = 'add'; + frame.original = { + url: { + host: 'example.com', + pathname: `/api/content/posts/`, + }, + }; + + return shared.headers.get(apiResult, apiConfigHeaders, frame).then((result) => { + assert.deepEqual(result, { + // NOTE: the backslash in the end is important to avoid unecessary 301s using the header + Location: 'https://example.com/api/content/posts/id_value/', + }); + }); + }); + + it('adds header when a location resolver is provided', function () { + const apiResult = { + posts: [ + { + id: 'id_value', + }, + ], + }; + + const resolvedLocationUrl = 'resolved location'; + + const apiConfigHeaders = { + location: { + resolve() { + return resolvedLocationUrl; + }, + }, + }; + const frame = new Frame(); + frame.docName = 'posts'; + frame.method = 'copy'; + frame.original = { + url: { + host: 'example.com', + pathname: `/api/content/posts/existing_post_id_value/copy`, + }, + }; + + return shared.headers.get(apiResult, apiConfigHeaders, frame).then((result) => { + assert.deepEqual(result, { + Location: resolvedLocationUrl, + }); + }); + }); + + it('respects HTTP redirects', async function () { + const apiResult = { + posts: [ + { + id: 'id_value', + }, + ], + }; + + const apiConfigHeaders = {}; + const frame = new Frame(); + + frame.docName = 'posts'; + frame.method = 'add'; + frame.original = { + url: { + host: 'example.com', + pathname: `/api/content/posts/`, + secure: false, + }, + }; + + const result = await shared.headers.get(apiResult, apiConfigHeaders, frame); + assert.deepEqual(result, { + // NOTE: the backslash in the end is important to avoid unecessary 301s using the header + Location: 'http://example.com/api/content/posts/id_value/', + }); + }); + + it('adds and resolves header to correct url when pathname does not contain backslash in the end', function () { + const apiResult = { + posts: [ + { + id: 'id_value', + }, + ], + }; + + const apiConfigHeaders = {}; + const frame = new Frame(); + frame.docName = 'posts'; + frame.method = 'add'; + frame.original = { + url: { + host: 'example.com', + pathname: `/api/content/posts`, + }, + }; + + return shared.headers.get(apiResult, apiConfigHeaders, frame).then((result) => { + assert.deepEqual(result, { + // NOTE: the backslash in the end is important to avoid unecessary 301s using the header + Location: 'https://example.com/api/content/posts/id_value/', + }); + }); + }); + + it('does not add header when missing result values', function () { + const apiResult = {}; + + const apiConfigHeaders = {}; + const frame = new Frame(); + frame.docName = 'posts'; + frame.method = 'add'; + frame.original = { + url: { + host: 'example.com', + pathname: `/api/content/posts/`, + }, + }; + + return shared.headers.get(apiResult, apiConfigHeaders, frame).then((result) => { + assert.deepEqual(result, {}); + }); + }); + }); +}); diff --git a/ghost/api-framework/test/http.test.js b/ghost/api-framework/test/http.test.js new file mode 100644 index 00000000000..4e27ee5418c --- /dev/null +++ b/ghost/api-framework/test/http.test.js @@ -0,0 +1,206 @@ +const assert = require('node:assert/strict'); +const sinon = require('sinon'); +const shared = require('../'); + +describe('HTTP', function () { + let req; + let res; + let next; + + beforeEach(function () { + req = sinon.stub(); + res = sinon.stub(); + next = sinon.stub(); + + req.body = { + a: 'a', + }; + req.vhost = { + host: 'example.com', + }; + req.get = sinon.stub().returns('fallback.example.com'); + req.originalUrl = '/ghost/api/content/posts/'; + req.secure = true; + req.url = 'https://example.com/ghost/api/content/'; + res.status = sinon.stub(); + res.json = sinon.stub(); + res.set = (headers) => { + res.headers = headers; + }; + res.send = sinon.stub(); + + sinon.stub(shared.headers, 'get').resolves(); + }); + + afterEach(function () { + sinon.restore(); + }); + + it('check options', function () { + const apiImpl = sinon.stub().resolves(); + shared.http(apiImpl)(req, res, next); + + assert.deepEqual(Object.keys(apiImpl.args[0][0]), [ + 'original', + 'options', + 'data', + 'user', + 'file', + 'files', + 'apiType', + 'docName', + 'method', + 'response', + ]); + + assert.deepEqual(apiImpl.args[0][0].data, { a: 'a' }); + assert.deepEqual(apiImpl.args[0][0].options, { + context: { + api_key: null, + integration: null, + user: null, + member: null, + }, + }); + }); + + it('api response is fn', async function () { + await new Promise((resolve) => { + const response = sinon.stub().callsFake(function (_req, _res, _next) { + assert.ok(_req); + assert.ok(_res); + assert.ok(_next); + assert.equal(apiImpl.calledOnce, true); + assert.equal(_res.json.called, false); + resolve(); + }); + + const apiImpl = sinon.stub().resolves(response); + shared.http(apiImpl)(req, res, next); + }); + }); + + it('api response is fn (data)', async function () { + await new Promise((resolve) => { + const apiImpl = sinon.stub().resolves('data'); + + next.callsFake(resolve); + + res.json.callsFake(function () { + assert.equal(shared.headers.get.calledOnce, true); + assert.equal(res.status.calledOnce, true); + assert.equal(res.send.called, false); + resolve(); + }); + + shared.http(apiImpl)(req, res, next); + }); + }); + + it('handles api key, user and plain text response', async function () { + await new Promise((resolve) => { + req.vhost = null; + req.user = { id: 'user-id' }; + req.api_key = { + get(key) { + return { + id: 'api-key-id', + type: 'admin', + integration_id: 'integration-id', + }[key]; + }, + }; + + const apiImpl = sinon.stub().resolves('plain body'); + apiImpl.response = { format: 'plain' }; + apiImpl.statusCode = 201; + + res.send.callsFake(() => { + assert.equal(res.status.calledOnceWithExactly(201), true); + assert.equal(res.headers.constructor, Object); + assert.equal(res.json.called, false); + + const frame = apiImpl.args[0][0]; + assert.equal(frame.options.context.api_key.id, 'api-key-id'); + assert.equal(frame.options.context.integration.id, 'integration-id'); + assert.equal(frame.options.context.user, 'user-id'); + resolve(); + }); + + shared.http(apiImpl)(req, res, next); + }); + }); + + it('supports async response format and statusCode function', async function () { + await new Promise((resolve) => { + const apiImpl = sinon.stub().resolves({ ok: true }); + apiImpl.statusCode = sinon.stub().returns(204); + apiImpl.response = { + format() { + return Promise.resolve('plain'); + }, + }; + + res.send.callsFake(() => { + assert.equal(apiImpl.statusCode.calledOnce, true); + assert.equal(res.status.calledOnceWithExactly(204), true); + resolve(); + }); + + shared.http(apiImpl)(req, res, next); + }); + }); + + it('supports sync response format function', async function () { + await new Promise((resolve) => { + const apiImpl = sinon.stub().resolves('plain body'); + apiImpl.response = { + format() { + return 'plain'; + }, + }; + + res.send.callsFake(() => { + assert.equal(res.send.calledOnce, true); + assert.equal(res.json.called, false); + resolve(); + }); + + shared.http(apiImpl)(req, res, next); + }); + }); + + it('passes errors to next with frame options', async function () { + await new Promise((resolve) => { + const error = new Error('failure'); + const apiImpl = sinon.stub().rejects(error); + + next.callsFake((err) => { + assert.equal(err, error); + assert.deepEqual(req.frameOptions, { + docName: null, + method: null, + }); + resolve(); + }); + + shared.http(apiImpl)(req, res, next); + }); + }); + + it('uses req.url pathname when originalUrl is missing', async function () { + await new Promise((resolve) => { + req.originalUrl = undefined; + req.url = '/ghost/api/content/posts/?include=authors'; + + const apiImpl = sinon.stub().resolves({}); + res.json.callsFake(() => { + const frame = apiImpl.args[0][0]; + assert.equal(frame.original.url.pathname, '/ghost/api/content/posts/'); + resolve(); + }); + + shared.http(apiImpl)(req, res, next); + }); + }); +}); diff --git a/ghost/api-framework/test/pipeline.test.js b/ghost/api-framework/test/pipeline.test.js new file mode 100644 index 00000000000..891427659ff --- /dev/null +++ b/ghost/api-framework/test/pipeline.test.js @@ -0,0 +1,652 @@ +const errors = require('@tryghost/errors'); +const assert = require('node:assert/strict'); +const sinon = require('sinon'); +const shared = require('../'); + +describe('Pipeline', function () { + afterEach(function () { + sinon.restore(); + }); + + describe('stages', function () { + describe('validation', function () { + describe('input', function () { + beforeEach(function () { + sinon.stub(shared.validators.handle, 'input').resolves(); + }); + + it('do it yourself', function () { + const apiUtils = {}; + const apiConfig = {}; + const apiImpl = { + validation: sinon.stub().resolves('response'), + }; + const frame = {}; + + return shared.pipeline.STAGES.validation + .input(apiUtils, apiConfig, apiImpl, frame) + .then((response) => { + assert.equal(response, 'response'); + + assert.equal(apiImpl.validation.calledOnce, true); + assert.equal(shared.validators.handle.input.called, false); + }); + }); + + it('default', function () { + const apiUtils = { + validators: { + input: { + posts: {}, + }, + }, + }; + const apiConfig = { + docName: 'posts', + }; + const apiImpl = { + options: ['include'], + validation: { + options: { + include: { + required: true, + }, + }, + }, + }; + const frame = { + options: {}, + }; + + return shared.pipeline.STAGES.validation + .input(apiUtils, apiConfig, apiImpl, frame) + .then(() => { + assert.equal(shared.validators.handle.input.calledOnce, true); + assert.equal( + shared.validators.handle.input.calledWith( + { + docName: 'posts', + options: { + include: { + required: true, + }, + }, + }, + { + posts: {}, + }, + { + options: {}, + }, + ), + true, + ); + }); + }); + }); + }); + + describe('serialisation', function () { + it('input calls shared serializer input handler', function () { + sinon.stub(shared.serializers.handle, 'input').resolves(); + + const apiUtils = { serializers: { input: { posts: {} } } }; + const apiConfig = { docName: 'posts', method: 'browse' }; + const apiImpl = { data: ['id'] }; + const frame = {}; + + return shared.pipeline.STAGES.serialisation + .input(apiUtils, apiConfig, apiImpl, frame) + .then(() => { + assert.equal(shared.serializers.handle.input.calledOnce, true); + assert.deepEqual(shared.serializers.handle.input.args[0][0], { + data: ['id'], + docName: 'posts', + method: 'browse', + }); + }); + }); + + it('output calls shared serializer output handler', function () { + sinon.stub(shared.serializers.handle, 'output').resolves(); + + const apiUtils = { serializers: { output: { posts: {} } } }; + const apiConfig = { docName: 'posts', method: 'browse' }; + const apiImpl = {}; + const frame = {}; + const response = [{ id: '1' }]; + + return shared.pipeline.STAGES.serialisation + .output(response, apiUtils, apiConfig, apiImpl, frame) + .then(() => { + assert.equal( + shared.serializers.handle.output.calledOnceWithExactly( + response, + apiConfig, + apiUtils.serializers.output, + frame, + ), + true, + ); + }); + }); + }); + + describe('permissions', function () { + let apiUtils; + + beforeEach(function () { + apiUtils = { + permissions: { + handle: sinon.stub().resolves(), + }, + }; + }); + + it('key is missing', function () { + const apiConfig = {}; + const apiImpl = {}; + const frame = {}; + + return shared.pipeline.STAGES.permissions(apiUtils, apiConfig, apiImpl, frame) + .then(Promise.reject) + .catch((err) => { + assert.equal(err instanceof errors.IncorrectUsageError, true); + assert.equal(apiUtils.permissions.handle.called, false); + }); + }); + + it('do it yourself', function () { + const apiConfig = {}; + const apiImpl = { + permissions: sinon.stub().resolves('lol'), + }; + const frame = {}; + + return shared.pipeline.STAGES.permissions(apiUtils, apiConfig, apiImpl, frame).then( + (response) => { + assert.equal(response, 'lol'); + assert.equal(apiImpl.permissions.calledOnce, true); + assert.equal(apiUtils.permissions.handle.called, false); + }, + ); + }); + + it('skip stage', function () { + const apiConfig = {}; + const apiImpl = { + permissions: false, + }; + const frame = {}; + + return shared.pipeline.STAGES.permissions(apiUtils, apiConfig, apiImpl, frame).then( + () => { + assert.equal(apiUtils.permissions.handle.called, false); + }, + ); + }); + + it('default', function () { + const apiConfig = {}; + const apiImpl = { + permissions: true, + }; + const frame = {}; + + return shared.pipeline.STAGES.permissions(apiUtils, apiConfig, apiImpl, frame).then( + () => { + assert.equal(apiUtils.permissions.handle.calledOnce, true); + }, + ); + }); + + it('with permission config', function () { + const apiConfig = { + docName: 'posts', + }; + const apiImpl = { + permissions: { + unsafeAttrs: ['test'], + }, + }; + const frame = { + options: {}, + }; + + return shared.pipeline.STAGES.permissions(apiUtils, apiConfig, apiImpl, frame).then( + () => { + assert.equal(apiUtils.permissions.handle.calledOnce, true); + assert.equal( + apiUtils.permissions.handle.calledWith( + { + docName: 'posts', + unsafeAttrs: ['test'], + }, + { + options: {}, + }, + ), + true, + ); + }, + ); + }); + + it('runs permission before hook', function () { + const before = sinon.stub().resolves(); + const apiConfig = {}; + const apiImpl = { + permissions: { + before, + }, + }; + const frame = {}; + + return shared.pipeline.STAGES.permissions(apiUtils, apiConfig, apiImpl, frame).then( + () => { + assert.equal(before.calledOnceWithExactly(frame), true); + assert.equal(apiUtils.permissions.handle.calledOnce, true); + }, + ); + }); + }); + + describe('query', function () { + it('throws when query method is missing', function () { + return shared.pipeline.STAGES.query({}, {}, {}, {}) + .then(Promise.reject) + .catch((err) => { + assert.equal(err instanceof errors.IncorrectUsageError, true); + }); + }); + + it('runs query when configured', function () { + const query = sinon.stub().resolves('result'); + const frame = {}; + return shared.pipeline.STAGES.query({}, {}, { query }, frame).then((result) => { + assert.equal(result, 'result'); + assert.equal(query.calledOnceWithExactly(frame), true); + }); + }); + }); + }); + + describe('pipeline', function () { + beforeEach(function () { + sinon.stub(shared.pipeline.STAGES.validation, 'input'); + sinon.stub(shared.pipeline.STAGES.serialisation, 'input'); + sinon.stub(shared.pipeline.STAGES.serialisation, 'output'); + sinon.stub(shared.pipeline.STAGES, 'permissions'); + sinon.stub(shared.pipeline.STAGES, 'query'); + }); + + it('ensure we receive a callable api controller fn', function () { + const apiController = { + add: {}, + browse: {}, + }; + + const apiUtils = {}; + + const result = shared.pipeline(apiController, apiUtils); + assert.equal(typeof result, 'object'); + + assert.ok(result.add); + assert.ok(result.browse); + assert.equal(typeof result.add, 'function'); + assert.equal(typeof result.browse, 'function'); + }); + + it('call api controller fn', function () { + const apiController = { + add: {}, + }; + + const apiUtils = {}; + const result = shared.pipeline(apiController, apiUtils); + + shared.pipeline.STAGES.validation.input.resolves(); + shared.pipeline.STAGES.serialisation.input.resolves(); + shared.pipeline.STAGES.permissions.resolves(); + shared.pipeline.STAGES.query.resolves('response'); + shared.pipeline.STAGES.serialisation.output.callsFake( + function (response, _apiUtils, apiConfig, apiImpl, frame) { + frame.response = response; + }, + ); + + return result.add().then((response) => { + assert.equal(response, 'response'); + + assert.equal(shared.pipeline.STAGES.validation.input.calledOnce, true); + assert.equal(shared.pipeline.STAGES.serialisation.input.calledOnce, true); + assert.equal(shared.pipeline.STAGES.permissions.calledOnce, true); + assert.equal(shared.pipeline.STAGES.query.calledOnce, true); + assert.equal(shared.pipeline.STAGES.serialisation.output.calledOnce, true); + }); + }); + + it('supports data and options arguments', function () { + const apiController = { + docName: 'posts', + add: { + headers: {}, + permissions: true, + query: sinon.stub().resolves('response'), + }, + }; + + const apiUtils = {}; + const result = shared.pipeline(apiController, apiUtils); + + shared.pipeline.STAGES.validation.input.resolves(); + shared.pipeline.STAGES.serialisation.input.resolves(); + shared.pipeline.STAGES.permissions.resolves(); + shared.pipeline.STAGES.query.resolves('response'); + shared.pipeline.STAGES.serialisation.output.callsFake( + function (response, _apiUtils, apiConfig, apiImpl, frame) { + frame.response = response; + }, + ); + + return result + .add({ posts: [{ title: 't' }] }, { context: { internal: true } }) + .then(() => { + const frame = shared.pipeline.STAGES.validation.input.args[0][3]; + assert.deepEqual(frame.data, { posts: [{ title: 't' }] }); + assert.deepEqual(frame.options.context, { internal: true }); + }); + }); + + it('supports single undefined argument by defaulting options', function () { + const apiController = { + docName: 'posts', + add: { + headers: {}, + permissions: true, + query: sinon.stub().resolves('response'), + }, + }; + + const apiUtils = {}; + const result = shared.pipeline(apiController, apiUtils); + + shared.pipeline.STAGES.validation.input.resolves(); + shared.pipeline.STAGES.serialisation.input.resolves(); + shared.pipeline.STAGES.permissions.resolves(); + shared.pipeline.STAGES.query.resolves('response'); + shared.pipeline.STAGES.serialisation.output.callsFake( + function (response, _apiUtils, apiConfig, apiImpl, frame) { + frame.response = response; + }, + ); + + return result.add(undefined).then(() => { + const frame = shared.pipeline.STAGES.validation.input.args[0][3]; + assert.deepEqual(frame.options.context, {}); + }); + }); + + it('api controller is fn, not config', function () { + const apiController = { + add() { + return Promise.resolve('response'); + }, + }; + + const apiUtils = {}; + const result = shared.pipeline(apiController, apiUtils); + + return result.add().then((response) => { + assert.equal(response, 'response'); + + assert.equal(shared.pipeline.STAGES.validation.input.called, false); + assert.equal(shared.pipeline.STAGES.serialisation.input.called, false); + assert.equal(shared.pipeline.STAGES.permissions.called, false); + assert.equal(shared.pipeline.STAGES.query.called, false); + assert.equal(shared.pipeline.STAGES.serialisation.output.called, false); + }); + }); + + it('uses existing frame instance and generateCacheKeyData', async function () { + const apiController = { + browse: { + headers: {}, + permissions: true, + generateCacheKeyData: sinon.stub().resolves({ custom: 'key' }), + query: sinon.stub().resolves('response'), + }, + }; + + const apiUtils = {}; + const result = shared.pipeline(apiController, apiUtils, 'content'); + const frame = new shared.Frame(); + + shared.pipeline.STAGES.validation.input.resolves(); + shared.pipeline.STAGES.serialisation.input.resolves(); + shared.pipeline.STAGES.permissions.resolves(); + shared.pipeline.STAGES.query.resolves('response'); + shared.pipeline.STAGES.serialisation.output.callsFake( + function (response, _apiUtils, apiConfig, apiImpl, frameArg) { + frameArg.response = response; + }, + ); + + const response = await result.browse(frame); + + assert.equal(response, 'response'); + assert.equal( + apiController.browse.generateCacheKeyData.calledOnceWithExactly(frame), + true, + ); + assert.equal(frame.apiType, 'content'); + assert.equal(frame.docName, undefined); + assert.equal(frame.method, 'browse'); + }); + + it('returns cached controller wrapper for same controller object', function () { + const apiController = { + docName: 'posts', + browse: { + headers: {}, + permissions: true, + query: sinon.stub().resolves('response'), + }, + }; + + const first = shared.pipeline(apiController, {}); + const second = shared.pipeline(apiController, {}); + + assert.equal(first, second); + }); + }); + + describe('caching', function () { + beforeEach(function () { + sinon.stub(shared.pipeline.STAGES.validation, 'input'); + sinon.stub(shared.pipeline.STAGES.serialisation, 'input'); + sinon.stub(shared.pipeline.STAGES.serialisation, 'output'); + sinon.stub(shared.pipeline.STAGES, 'permissions'); + sinon.stub(shared.pipeline.STAGES, 'query'); + }); + + it('should set a cache if configured on endpoint level', async function () { + const apiController = { + browse: { + cache: { + get: sinon.stub().resolves(null), + set: sinon.stub().resolves(true), + }, + }, + }; + + const apiUtils = {}; + const result = shared.pipeline(apiController, apiUtils); + + shared.pipeline.STAGES.validation.input.resolves(); + shared.pipeline.STAGES.serialisation.input.resolves(); + shared.pipeline.STAGES.permissions.resolves(); + shared.pipeline.STAGES.query.resolves('response'); + shared.pipeline.STAGES.serialisation.output.callsFake( + function (response, _apiUtils, apiConfig, apiImpl, frame) { + frame.response = response; + }, + ); + + const response = await result.browse(); + + assert.equal(response, 'response'); + + // request went through all stages + assert.equal(shared.pipeline.STAGES.validation.input.calledOnce, true); + assert.equal(shared.pipeline.STAGES.serialisation.input.calledOnce, true); + assert.equal(shared.pipeline.STAGES.permissions.calledOnce, true); + assert.equal(shared.pipeline.STAGES.query.calledOnce, true); + assert.equal(shared.pipeline.STAGES.serialisation.output.calledOnce, true); + + // cache was set + assert.equal(apiController.browse.cache.set.calledOnce, true); + assert.equal(apiController.browse.cache.set.args[0][1], 'response'); + }); + + it('should use cache if configured on endpoint level', async function () { + const apiController = { + browse: { + cache: { + get: sinon.stub().resolves('CACHED RESPONSE'), + set: sinon.stub().resolves(true), + }, + }, + }; + + const apiUtils = {}; + const result = shared.pipeline(apiController, apiUtils); + + shared.pipeline.STAGES.validation.input.resolves(); + shared.pipeline.STAGES.serialisation.input.resolves(); + shared.pipeline.STAGES.permissions.resolves(); + shared.pipeline.STAGES.query.resolves('response'); + shared.pipeline.STAGES.serialisation.output.callsFake( + function (response, _apiUtils, apiConfig, apiImpl, frame) { + frame.response = response; + }, + ); + + const response = await result.browse(); + + assert.equal(response, 'CACHED RESPONSE'); + + // request went through all stages + assert.equal(shared.pipeline.STAGES.validation.input.calledOnce, false); + assert.equal(shared.pipeline.STAGES.serialisation.input.calledOnce, false); + assert.equal(shared.pipeline.STAGES.permissions.calledOnce, false); + assert.equal(shared.pipeline.STAGES.query.calledOnce, false); + assert.equal(shared.pipeline.STAGES.serialisation.output.calledOnce, false); + + // cache not set + assert.equal(apiController.browse.cache.set.calledOnce, false); + }); + + it('produces distinct cache keys when cacheKeyData objects differ only in nested fields', async function () { + const cache = { + get: sinon.stub().resolves(null), + set: sinon.stub().resolves(true), + }; + + const apiController = { + browse: { + cache, + generateCacheKeyData: sinon.stub(), + }, + }; + + const apiUtils = {}; + const result = shared.pipeline(apiController, apiUtils); + + shared.pipeline.STAGES.validation.input.resolves(); + shared.pipeline.STAGES.serialisation.input.resolves(); + shared.pipeline.STAGES.permissions.resolves(); + shared.pipeline.STAGES.query.resolves('response'); + shared.pipeline.STAGES.serialisation.output.callsFake( + function (response, _apiUtils, apiConfig, apiImpl, frame) { + frame.response = response; + }, + ); + + // Two requests that share every top-level key but differ deep inside + // a nested object must not collide. Top-level keys are identical on + // purpose — that is what the replacer-array form silently dropped. + apiController.browse.generateCacheKeyData.onFirstCall().resolves({ + options: { filter: 'status:published', limit: 15 }, + auth: { free: true, tiers: [] }, + method: 'browse', + }); + apiController.browse.generateCacheKeyData.onSecondCall().resolves({ + options: { filter: 'status:published', limit: 15 }, + auth: { free: false, tiers: ['gold'] }, + method: 'browse', + }); + + await result.browse(); + await result.browse(); + + const firstKey = cache.get.firstCall.args[0]; + const secondKey = cache.get.secondCall.args[0]; + + assert.notEqual( + firstKey, + secondKey, + 'nested-field differences must produce distinct cache keys', + ); + }); + + it('produces identical cache keys when cacheKeyData objects are reordered at any depth', async function () { + const cache = { + get: sinon.stub().resolves(null), + set: sinon.stub().resolves(true), + }; + + const apiController = { + browse: { + cache, + generateCacheKeyData: sinon.stub(), + }, + }; + + const apiUtils = {}; + const result = shared.pipeline(apiController, apiUtils); + + shared.pipeline.STAGES.validation.input.resolves(); + shared.pipeline.STAGES.serialisation.input.resolves(); + shared.pipeline.STAGES.permissions.resolves(); + shared.pipeline.STAGES.query.resolves('response'); + shared.pipeline.STAGES.serialisation.output.callsFake( + function (response, _apiUtils, apiConfig, apiImpl, frame) { + frame.response = response; + }, + ); + + // Same logical payload, keys inserted in different orders at every + // depth (top-level, options, auth, and an object nested inside an + // array). Insertion order must not affect the cache key. + apiController.browse.generateCacheKeyData.onFirstCall().resolves({ + options: { filter: 'status:published', limit: 15 }, + auth: { free: false, tiers: [{ slug: 'gold', order: 1 }] }, + method: 'browse', + }); + apiController.browse.generateCacheKeyData.onSecondCall().resolves({ + method: 'browse', + auth: { tiers: [{ order: 1, slug: 'gold' }], free: false }, + options: { limit: 15, filter: 'status:published' }, + }); + + await result.browse(); + await result.browse(); + + const firstKey = cache.get.firstCall.args[0]; + const secondKey = cache.get.secondCall.args[0]; + + assert.equal(firstKey, secondKey, 'key insertion order must not affect the cache key'); + }); + }); +}); diff --git a/ghost/api-framework/test/serializers/handle.test.js b/ghost/api-framework/test/serializers/handle.test.js new file mode 100644 index 00000000000..e7af944073f --- /dev/null +++ b/ghost/api-framework/test/serializers/handle.test.js @@ -0,0 +1,466 @@ +const errors = require('@tryghost/errors'); +const assert = require('node:assert/strict'); +const sinon = require('sinon'); +const shared = require('../../'); + +describe('serializers/handle', function () { + afterEach(function () { + sinon.restore(); + }); + + describe('input', function () { + it('no api config passed', function () { + return shared.serializers.handle + .input() + .then(Promise.reject) + .catch((err) => { + assert.equal(err instanceof errors.IncorrectUsageError, true); + }); + }); + + it('no api serializers passed', function () { + return shared.serializers.handle + .input({}) + .then(Promise.reject) + .catch((err) => { + assert.equal(err instanceof errors.IncorrectUsageError, true); + }); + }); + + it('ensure default serializers are called with apiConfig and frame', function () { + const allStub = sinon.stub(); + sinon.stub(shared.serializers.input.all, 'all').get(() => allStub); + + const apiSerializers = { + all: sinon.stub().resolves(), + posts: { + all: sinon.stub().resolves(), + browse: sinon.stub().resolves(), + }, + }; + + const apiConfig = { docName: 'posts', method: 'browse' }; + const frame = {}; + + const stubsToCheck = [ + allStub, + apiSerializers.all, + apiSerializers.posts.all, + apiSerializers.posts.browse, + ]; + + return shared.serializers.handle.input(apiConfig, apiSerializers, frame).then(() => { + stubsToCheck.forEach((stub) => { + sinon.assert.calledOnceWithExactly(stub, apiConfig, frame); + }); + }); + }); + + it('ensure serializers are called with apiConfig and frame if new shared serializer is added', function () { + const allStub = sinon.stub(); + const allBrowseStub = sinon.stub(); + + shared.serializers.input.all.browse = allBrowseStub; + + sinon.stub(shared.serializers.input.all, 'all').get(() => allStub); + + const apiSerializers = { + all: sinon.stub().resolves(), + posts: { + all: sinon.stub().resolves(), + browse: sinon.stub().resolves(), + }, + }; + + const apiConfig = { docName: 'posts', method: 'browse' }; + const frame = {}; + + const stubsToCheck = [ + allStub, + allBrowseStub, + apiSerializers.all, + apiSerializers.posts.all, + apiSerializers.posts.browse, + ]; + + return shared.serializers.handle.input(apiConfig, apiSerializers, frame).then(() => { + stubsToCheck.forEach((stub) => { + sinon.assert.calledOnceWithExactly(stub, apiConfig, frame); + }); + + sinon.assert.callOrder( + allStub, + allBrowseStub, + apiSerializers.all, + apiSerializers.posts.all, + apiSerializers.posts.browse, + ); + }); + }); + }); + + describe('output', function () { + let apiSerializers, response, apiConfig, frame; + + beforeEach(function () { + response = []; + apiConfig = { docName: 'posts', method: 'add' }; + frame = {}; + }); + + it('no models passed', function () { + return shared.serializers.handle.output(null, {}, {}, {}); + }); + + it('no api config passed', function () { + return shared.serializers.handle + .output([]) + .then(Promise.reject) + .catch((err) => { + assert.equal(err instanceof errors.IncorrectUsageError, true); + }); + }); + + it('no api serializers passed', function () { + return shared.serializers.handle + .output([], {}) + .then(Promise.reject) + .catch((err) => { + assert.equal(err instanceof errors.IncorrectUsageError, true); + }); + }); + + describe('Specific serializers only', function () { + beforeEach(function () { + apiSerializers = { + posts: { + add: sinon.stub().resolves(), + }, + users: { + add: sinon.stub().resolves(), + }, + }; + }); + + it('correct custom serializer is called', function () { + return shared.serializers.handle + .output(response, apiConfig, apiSerializers, frame) + .then(() => { + sinon.assert.calledOnceWithExactly( + apiSerializers.posts.add, + response, + apiConfig, + frame, + ); + sinon.assert.notCalled(apiSerializers.users.add); + }); + }); + + it('no serializer called if there is no match', function () { + apiConfig = { docName: 'posts', method: 'idontexist' }; + + return shared.serializers.handle + .output(response, apiConfig, apiSerializers, frame) + .then(() => { + sinon.assert.notCalled(apiSerializers.posts.add); + sinon.assert.notCalled(apiSerializers.users.add); + }); + }); + }); + + describe('Custom and global (all) serializers', function () { + beforeEach(function () { + apiSerializers = { + all: { + after: sinon.stub().resolves(), + before: sinon.stub().resolves(), + }, + posts: { + add: sinon.stub().resolves(), + all: sinon.stub().resolves(), + }, + }; + }); + + it('calls custom serializer if one exists', function () { + const stubsToCheck = [apiSerializers.all.before, apiSerializers.posts.add]; + + return shared.serializers.handle + .output(response, apiConfig, apiSerializers, frame) + .then(() => { + stubsToCheck.forEach((stub) => { + sinon.assert.calledOnceWithExactly(stub, response, apiConfig, frame); + }); + + // After has a different call signature... is this a intentional? + sinon.assert.calledOnceWithExactly( + apiSerializers.all.after, + apiConfig, + frame, + ); + + sinon.assert.callOrder( + apiSerializers.all.before, + apiSerializers.posts.add, + apiSerializers.all.after, + ); + + sinon.assert.notCalled(apiSerializers.posts.all); + }); + }); + + it('calls all serializer if custom one does not exist', function () { + apiConfig = { docName: 'posts', method: 'idontexist' }; + + const stubsToCheck = [apiSerializers.all.before, apiSerializers.posts.all]; + + return shared.serializers.handle + .output(response, apiConfig, apiSerializers, frame) + .then(() => { + stubsToCheck.forEach((stub) => { + sinon.assert.calledOnceWithExactly(stub, response, apiConfig, frame); + }); + + // After has a different call signature... is this a intentional? + sinon.assert.calledOnceWithExactly( + apiSerializers.all.after, + apiConfig, + frame, + ); + + sinon.assert.callOrder( + apiSerializers.all.before, + apiSerializers.posts.all, + apiSerializers.all.after, + ); + + sinon.assert.notCalled(apiSerializers.posts.add); + }); + }); + }); + + describe('Custom, default and global (all) serializers with no custom fallback', function () { + beforeEach(function () { + apiSerializers = { + all: { + after: sinon.stub().resolves(), + before: sinon.stub().resolves(), + }, + default: { + add: sinon.stub().resolves(), + all: sinon.stub().resolves(), + }, + posts: { + add: sinon.stub().resolves(), + }, + }; + }); + + it('uses best match serializer when custom match exists', function () { + const stubsToCheck = [apiSerializers.all.before, apiSerializers.posts.add]; + + return shared.serializers.handle + .output(response, apiConfig, apiSerializers, frame) + .then(() => { + stubsToCheck.forEach((stub) => { + sinon.assert.calledOnceWithExactly(stub, response, apiConfig, frame); + }); + + // After has a different call signature... is this a intentional? + sinon.assert.calledOnceWithExactly( + apiSerializers.all.after, + apiConfig, + frame, + ); + + sinon.assert.callOrder( + apiSerializers.all.before, + apiSerializers.posts.add, + apiSerializers.all.after, + ); + + sinon.assert.notCalled(apiSerializers.default.add); + sinon.assert.notCalled(apiSerializers.default.all); + }); + }); + + it('uses nearest fallback serializer when custom match does not exist', function () { + apiConfig = { docName: 'posts', method: 'idontexist' }; + + const stubsToCheck = [apiSerializers.all.before, apiSerializers.default.all]; + + return shared.serializers.handle + .output(response, apiConfig, apiSerializers, frame) + .then(() => { + stubsToCheck.forEach((stub) => { + sinon.assert.calledOnceWithExactly(stub, response, apiConfig, frame); + }); + + // After has a different call signature... is this a intentional? + sinon.assert.calledOnceWithExactly( + apiSerializers.all.after, + apiConfig, + frame, + ); + + sinon.assert.callOrder( + apiSerializers.all.before, + apiSerializers.default.all, + apiSerializers.all.after, + ); + + sinon.assert.notCalled(apiSerializers.posts.add); + sinon.assert.notCalled(apiSerializers.default.add); + }); + }); + }); + + describe('Custom, default and global (all) serializers with custom fallback', function () { + beforeEach(function () { + apiSerializers = { + all: { + after: sinon.stub().resolves(), + before: sinon.stub().resolves(), + }, + default: { + add: sinon.stub().resolves(), + all: sinon.stub().resolves(), + }, + posts: { + add: sinon.stub().resolves(), + all: sinon.stub().resolves(), + }, + }; + }); + + it('uses best match serializer when custom match exists', function () { + const stubsToCheck = [apiSerializers.all.before, apiSerializers.posts.add]; + + return shared.serializers.handle + .output(response, apiConfig, apiSerializers, frame) + .then(() => { + stubsToCheck.forEach((stub) => { + sinon.assert.calledOnceWithExactly(stub, response, apiConfig, frame); + }); + + // After has a different call signature... is this a intentional? + sinon.assert.calledOnceWithExactly( + apiSerializers.all.after, + apiConfig, + frame, + ); + + sinon.assert.callOrder( + apiSerializers.all.before, + apiSerializers.posts.add, + apiSerializers.all.after, + ); + + sinon.assert.notCalled(apiSerializers.posts.all); + sinon.assert.notCalled(apiSerializers.default.add); + sinon.assert.notCalled(apiSerializers.default.all); + }); + }); + + it('uses nearest fallback serializer when custom match does not exist', function () { + apiConfig = { docName: 'posts', method: 'idontexist' }; + + const stubsToCheck = [apiSerializers.all.before, apiSerializers.posts.all]; + + return shared.serializers.handle + .output(response, apiConfig, apiSerializers, frame) + .then(() => { + stubsToCheck.forEach((stub) => { + sinon.assert.calledOnceWithExactly(stub, response, apiConfig, frame); + }); + + // After has a different call signature... is this a intentional? + sinon.assert.calledOnceWithExactly( + apiSerializers.all.after, + apiConfig, + frame, + ); + + sinon.assert.callOrder( + apiSerializers.all.before, + apiSerializers.posts.all, + apiSerializers.all.after, + ); + + sinon.assert.notCalled(apiSerializers.posts.add); + sinon.assert.notCalled(apiSerializers.default.add); + sinon.assert.notCalled(apiSerializers.default.all); + }); + }); + }); + + describe('Default and global (all) serializers work together correctly', function () { + beforeEach(function () { + apiSerializers = { + all: { + after: sinon.stub().resolves(), + before: sinon.stub().resolves(), + }, + default: { + add: sinon.stub().resolves(), + all: sinon.stub().resolves(), + }, + }; + }); + + it('correctly calls default serializer when no custom one is set', function () { + const stubsToCheck = [apiSerializers.all.before, apiSerializers.default.add]; + + return shared.serializers.handle + .output(response, apiConfig, apiSerializers, frame) + .then(() => { + stubsToCheck.forEach((stub) => { + sinon.assert.calledOnceWithExactly(stub, response, apiConfig, frame); + }); + + // After has a different call signature... is this a intentional? + sinon.assert.calledOnceWithExactly( + apiSerializers.all.after, + apiConfig, + frame, + ); + + sinon.assert.callOrder( + apiSerializers.all.before, + apiSerializers.default.add, + apiSerializers.all.after, + ); + sinon.assert.notCalled(apiSerializers.default.all); + }); + }); + + it('correctly uses fallback serializer when there is no default match', function () { + apiConfig = { docName: 'posts', method: 'idontexist' }; + + const stubsToCheck = [apiSerializers.all.before, apiSerializers.default.all]; + + return shared.serializers.handle + .output(response, apiConfig, apiSerializers, frame) + .then(() => { + stubsToCheck.forEach((stub) => { + sinon.assert.calledOnceWithExactly(stub, response, apiConfig, frame); + }); + + // After has a different call signature... is this a intentional? + sinon.assert.calledOnceWithExactly( + apiSerializers.all.after, + apiConfig, + frame, + ); + + sinon.assert.callOrder( + apiSerializers.all.before, + apiSerializers.default.all, + apiSerializers.all.after, + ); + sinon.assert.notCalled(apiSerializers.default.add); + }); + }); + }); + }); +}); diff --git a/ghost/api-framework/test/serializers/input/all.test.js b/ghost/api-framework/test/serializers/input/all.test.js new file mode 100644 index 00000000000..fcf73265022 --- /dev/null +++ b/ghost/api-framework/test/serializers/input/all.test.js @@ -0,0 +1,81 @@ +const assert = require('node:assert/strict'); +const shared = require('../../../'); + +describe('serializers/input/all', function () { + describe('all', function () { + it('transforms into model readable format', function () { + const apiConfig = {}; + const frame = { + original: { + include: 'tags', + fields: 'id,status', + formats: 'html', + }, + options: { + include: 'tags', + fields: 'id,status', + formats: 'html', + context: {}, + }, + }; + + shared.serializers.input.all.all(apiConfig, frame); + + assert.ok(frame.original.include); + assert.ok(frame.original.fields); + assert.ok(frame.original.formats); + + assert.equal(frame.options.include, undefined); + assert.equal(frame.options.fields, undefined); + assert.ok(frame.options.formats); + assert.ok(frame.options.columns); + assert.ok(frame.options.withRelated); + + assert.deepEqual(frame.options.withRelated, ['tags']); + assert.deepEqual(frame.options.columns, ['id', 'status', 'html']); + assert.deepEqual(frame.options.formats, ['html']); + }); + + describe('extra allowed internal options', function () { + it('internal access', function () { + const frame = { + options: { + context: { + internal: true, + }, + transacting: true, + forUpdate: true, + }, + }; + + const apiConfig = {}; + + shared.serializers.input.all.all(apiConfig, frame); + + assert.ok(frame.options.transacting); + assert.ok(frame.options.forUpdate); + assert.ok(frame.options.context); + }); + + it('no internal access', function () { + const frame = { + options: { + context: { + user: true, + }, + transacting: true, + forUpdate: true, + }, + }; + + const apiConfig = {}; + + shared.serializers.input.all.all(apiConfig, frame); + + assert.equal(frame.options.transacting, undefined); + assert.equal(frame.options.forUpdate, undefined); + assert.ok(frame.options.context); + }); + }); + }); +}); diff --git a/ghost/api-framework/test/util/options.test.js b/ghost/api-framework/test/util/options.test.js new file mode 100644 index 00000000000..be5ed83b847 --- /dev/null +++ b/ghost/api-framework/test/util/options.test.js @@ -0,0 +1,33 @@ +const assert = require('node:assert/strict'); +const optionsUtil = require('../../lib/utils/options'); + +describe('util/options', function () { + it('returns an array with empty string when no parameters are passed', function () { + assert.deepEqual(optionsUtil.trimAndLowerCase(), ['']); + }); + + it('returns single item array', function () { + assert.deepEqual(optionsUtil.trimAndLowerCase('butter'), ['butter']); + }); + + it('returns multiple items in array', function () { + assert.deepEqual(optionsUtil.trimAndLowerCase('peanut, butter'), ['peanut', 'butter']); + }); + + it('lowercases and trims items in the string', function () { + assert.deepEqual(optionsUtil.trimAndLowerCase(' PeanUt, buTTer '), ['peanut', 'butter']); + }); + + it('accepts parameters in form of an array', function () { + assert.deepEqual(optionsUtil.trimAndLowerCase([' PeanUt', ' buTTer ']), [ + 'peanut', + 'butter', + ]); + }); + + it('throws error for invalid object input', function () { + assert.throws(() => optionsUtil.trimAndLowerCase({ name: 'peanut' }), { + message: 'Params must be a string or array', + }); + }); +}); diff --git a/ghost/api-framework/test/validators/handle.test.js b/ghost/api-framework/test/validators/handle.test.js new file mode 100644 index 00000000000..ce079e83863 --- /dev/null +++ b/ghost/api-framework/test/validators/handle.test.js @@ -0,0 +1,86 @@ +const errors = require('@tryghost/errors'); +const assert = require('node:assert/strict'); +const sinon = require('sinon'); +const shared = require('../../'); + +describe('validators/handle', function () { + afterEach(function () { + sinon.restore(); + }); + + describe('input', function () { + it('no api config passed', function () { + return shared.validators.handle + .input() + .then(Promise.reject) + .catch((err) => { + assert.equal(err instanceof errors.IncorrectUsageError, true); + }); + }); + + it('no api validators passed', function () { + return shared.validators.handle + .input({}) + .then(Promise.reject) + .catch((err) => { + assert.equal(err instanceof errors.IncorrectUsageError, true); + }); + }); + + it('no api config passed when validators exist', function () { + return shared.validators.handle + .input(undefined, {}, {}) + .then(Promise.reject) + .catch((err) => { + assert.equal(err instanceof errors.IncorrectUsageError, true); + }); + }); + + it('ensure validators are called', function () { + const getStub = sinon.stub(); + const addStub = sinon.stub(); + sinon.stub(shared.validators.input.all, 'all').get(() => { + return getStub; + }); + sinon.stub(shared.validators.input.all, 'add').get(() => { + return addStub; + }); + + const apiValidators = { + all: { + add: sinon.stub().resolves(), + }, + posts: { + add: sinon.stub().resolves(), + }, + users: { + add: sinon.stub().resolves(), + }, + }; + + return shared.validators.handle + .input({ docName: 'posts', method: 'add' }, apiValidators, { context: {} }) + .then(() => { + assert.equal(getStub.calledOnce, true); + assert.equal(addStub.calledOnce, true); + assert.equal(apiValidators.all.add.calledOnce, true); + assert.equal(apiValidators.posts.add.calledOnce, true); + assert.equal(apiValidators.users.add.called, false); + }); + }); + + it('calls docName all validator when provided', function () { + const apiValidators = { + posts: { + all: sinon.stub().resolves(), + }, + }; + + return shared.validators.handle + .input({ docName: 'posts', method: 'browse' }, apiValidators, {}) + .then(() => { + assert.equal(apiValidators.posts.all.calledOnce, true); + }); + }); + }); +}); diff --git a/ghost/api-framework/test/validators/input/all.test.js b/ghost/api-framework/test/validators/input/all.test.js new file mode 100644 index 00000000000..4f20b2e22e3 --- /dev/null +++ b/ghost/api-framework/test/validators/input/all.test.js @@ -0,0 +1,592 @@ +const errors = require('@tryghost/errors'); +const assert = require('node:assert/strict'); +const sinon = require('sinon'); +const shared = require('../../../'); + +describe('validators/input/all', function () { + afterEach(function () { + sinon.restore(); + }); + + describe('all', function () { + it('default', function () { + const frame = { + options: { + context: {}, + slug: 'slug', + include: 'tags,authors', + page: 2, + }, + }; + + const apiConfig = { + options: { + include: { + values: ['tags', 'authors'], + required: true, + }, + }, + }; + + return shared.validators.input.all.all(apiConfig, frame).then(() => { + assert.ok(frame.options.page); + assert.ok(frame.options.slug); + assert.ok(frame.options.include); + assert.ok(frame.options.context); + }); + }); + + it('should run global validations on an type that has validation defined', function () { + const frame = { + options: { + slug: 'not a valid slug %%%%% http://', + }, + }; + + const apiConfig = { + options: { + slug: { + required: true, + }, + }, + }; + + return shared.validators.input.all.all(apiConfig, frame).then( + () => { + throw new Error('Should not resolve'); + }, + (err) => { + assert.ok(err); + }, + ); + }); + + it('allows empty values', function () { + const frame = { + options: { + context: {}, + formats: '', + }, + }; + + const apiConfig = { + options: { + formats: ['format1'], + }, + }; + + return shared.validators.input.all.all(apiConfig, frame); + }); + + it('supports include being an array', function () { + const frame = { + options: { + context: {}, + slug: 'slug', + include: ['tags', 'authors'], + page: 2, + }, + }; + + const apiConfig = { + options: { + include: { + values: ['tags', 'authors'], + required: true, + }, + }, + }; + + return shared.validators.input.all.all(apiConfig, frame).then(() => { + assert.ok(frame.options.page); + assert.ok(frame.options.slug); + assert.ok(frame.options.include); + assert.ok(frame.options.context); + }); + }); + + it('default include array notation', function () { + const frame = { + options: { + context: {}, + slug: 'slug', + include: 'tags,authors', + page: 2, + }, + }; + + const apiConfig = { + options: { + include: ['tags', 'authors'], + }, + }; + + return shared.validators.input.all.all(apiConfig, frame).then(() => { + assert.ok(frame.options.page); + assert.ok(frame.options.slug); + assert.ok(frame.options.include); + assert.ok(frame.options.context); + }); + }); + + it('does not fail', function () { + const frame = { + options: { + context: {}, + include: 'tags,authors', + }, + }; + + const apiConfig = { + options: { + include: { + values: ['tags'], + }, + }, + }; + + return shared.validators.input.all + .all(apiConfig, frame) + .then(Promise.reject.bind(Promise)) + .catch((err) => { + assert.equal(err, undefined); + }); + }); + + it('does not fail include array notation', function () { + const frame = { + options: { + context: {}, + include: 'tags,authors', + }, + }; + + const apiConfig = { + options: { + include: ['tags'], + }, + }; + + return shared.validators.input.all + .all(apiConfig, frame) + .then(Promise.reject.bind(Promise)) + .catch((err) => { + assert.equal(err, undefined); + }); + }); + + it('fails', function () { + const frame = { + options: { + context: {}, + }, + }; + + const apiConfig = { + options: { + include: { + required: true, + }, + }, + }; + + return shared.validators.input.all + .all(apiConfig, frame) + .then(Promise.reject) + .catch((err) => { + assert.ok(err); + }); + }); + + it('invalid fields', function () { + const frame = { + options: { + context: {}, + id: 'invalid', + }, + }; + + const apiConfig = {}; + + return shared.validators.input.all + .all(apiConfig, frame) + .then(Promise.reject) + .catch((err) => { + assert.ok(err); + }); + }); + + it('allows supported id values', async function () { + const ids = ['a'.repeat(24), '1', 'me', 'ME']; + + for (const id of ids) { + const frame = { + options: { + context: {}, + id, + }, + }; + + await shared.validators.input.all.all({}, frame); + } + }); + + it('rejects id values that contain me as a substring', async function () { + const ids = [ + "aaaaaaaaaaaaaaaaaaaaaaaa',member_id:'aaaaaaaaaaaaaaaaaaaaaaaa", + 'member_id', + 'not-me', + 'me123', + '123me', + ]; + + for (const id of ids) { + const frame = { + options: { + context: {}, + id, + }, + }; + + await assert.rejects(shared.validators.input.all.all({}, frame), (err) => { + assert.equal(err.message, 'Validation (matches) failed for id'); + return true; + }); + } + }); + + it('allows supported limit values', async function () { + const limits = ['10', '0', 'all']; + + for (const limit of limits) { + const frame = { + options: { + context: {}, + limit, + }, + }; + + await shared.validators.input.all.all({}, frame); + } + }); + + it('rejects limit values that only partially match supported values', async function () { + const limits = ['10junk', 'junkall', '10all']; + + for (const limit of limits) { + const frame = { + options: { + context: {}, + limit, + }, + }; + + await assert.rejects(shared.validators.input.all.all({}, frame), (err) => { + assert.equal(err.message, 'Validation (matches) failed for limit'); + return true; + }); + } + }); + + it('fails on invalid allowed values for non-include fields', function () { + const frame = { + options: { + context: {}, + formats: 'mobiledoc', + }, + }; + + const apiConfig = { + options: { + formats: ['html'], + }, + }; + + return shared.validators.input.all + .all(apiConfig, frame) + .then(Promise.reject) + .catch((err) => { + assert.ok(err); + assert.equal(err.message, 'Validation (AllowedValues) failed for formats'); + }); + }); + }); + + describe('browse', function () { + it('default', function () { + const frame = { + options: { + context: {}, + }, + data: { + status: 'aus', + }, + }; + + const apiConfig = {}; + + shared.validators.input.all.browse(apiConfig, frame); + assert.ok(frame.options.context); + assert.ok(frame.data.status); + }); + + it('fails', function () { + const frame = { + options: { + context: {}, + }, + data: { + id: 'no-id', + }, + }; + + const apiConfig = {}; + + return shared.validators.input.all + .browse(apiConfig, frame) + .then(Promise.reject) + .catch((err) => { + assert.ok(err); + }); + }); + }); + + describe('read', function () { + it('default', function () { + sinon.stub(shared.validators.input.all, 'browse'); + + const frame = { + options: { + context: {}, + }, + }; + + const apiConfig = {}; + + shared.validators.input.all.read(apiConfig, frame); + assert.equal(shared.validators.input.all.browse.calledOnce, true); + }); + }); + + describe('add', function () { + it('fails', function () { + const frame = { + data: {}, + }; + + const apiConfig = { + docName: 'docName', + }; + + return shared.validators.input.all + .add(apiConfig, frame) + .then(Promise.reject) + .catch((err) => { + assert.ok(err); + }); + }); + + it('fails with docName', function () { + const frame = { + data: { + docName: true, + }, + }; + + const apiConfig = { + docName: 'docName', + }; + + return shared.validators.input.all + .add(apiConfig, frame) + .then(Promise.reject) + .catch((err) => { + assert.ok(err); + }); + }); + + it('fails for required field', function () { + const frame = { + data: { + docName: [ + { + a: 'b', + }, + ], + }, + }; + + const apiConfig = { + docName: 'docName', + data: { + b: { + required: true, + }, + }, + }; + + return shared.validators.input.all + .add(apiConfig, frame) + .then(Promise.reject) + .catch((err) => { + assert.ok(err); + assert.equal(err.message, 'Validation (FieldIsRequired) failed for ["b"]'); + }); + }); + + it('fails for invalid field', function () { + const frame = { + data: { + docName: [ + { + a: 'b', + b: null, + }, + ], + }, + }; + + const apiConfig = { + docName: 'docName', + data: { + b: { + required: true, + }, + }, + }; + + return shared.validators.input.all + .add(apiConfig, frame) + .then(Promise.reject) + .catch((err) => { + assert.ok(err); + assert.equal(err.message, 'Validation (FieldIsInvalid) failed for ["b"]'); + }); + }); + + it('success', function () { + const frame = { + data: { + docName: [ + { + a: 'b', + }, + ], + }, + }; + + const apiConfig = { + docName: 'docName', + }; + + const result = shared.validators.input.all.add(apiConfig, frame); + assert.equal(result instanceof Promise, false); + }); + }); + + describe('edit', function () { + it('id mismatch', function () { + const apiConfig = { + docName: 'users', + }; + + const frame = { + options: { + id: 'zwei', + }, + data: { + posts: [ + { + id: 'eins', + }, + ], + }, + }; + + return shared.validators.input.all + .edit(apiConfig, frame) + .then(Promise.reject) + .catch((err) => { + assert.equal(err instanceof errors.BadRequestError, true); + }); + }); + + it('returns add promise result when add fails', function () { + sinon + .stub(shared.validators.input.all, 'add') + .returns(Promise.reject(new Error('add-failed'))); + return shared.validators.input.all + .edit({}, {}) + .then(Promise.reject) + .catch((err) => { + assert.equal(err.message, 'add-failed'); + }); + }); + + it('checks id mismatch after successful add for non posts/tags', function () { + sinon.stub(shared.validators.input.all, 'add').returns(undefined); + + return shared.validators.input.all + .edit( + { + docName: 'users', + }, + { + options: { + id: 'id-1', + }, + data: { + users: [{ id: 'id-2' }], + }, + }, + ) + .then(Promise.reject) + .catch((err) => { + assert.equal(err instanceof errors.BadRequestError, true); + assert.equal(err.message, 'Invalid id provided.'); + }); + }); + + it('does not check id mismatch for posts/tags', function () { + sinon.stub(shared.validators.input.all, 'add').returns(undefined); + const result = shared.validators.input.all.edit( + { + docName: 'posts', + }, + { + options: { id: 'id-1' }, + data: { + posts: [{ id: 'id-2' }], + }, + }, + ); + assert.equal(result, undefined); + }); + }); + + describe('delegated methods', function () { + it('changePassword delegates to add', function () { + sinon.stub(shared.validators.input.all, 'add').returns('add-result'); + const result = shared.validators.input.all.changePassword({}, {}); + assert.equal(result, 'add-result'); + }); + + it('resetPassword delegates to add', function () { + sinon.stub(shared.validators.input.all, 'add').returns('add-result'); + const result = shared.validators.input.all.resetPassword({}, {}); + assert.equal(result, 'add-result'); + }); + + it('setup delegates to add', function () { + sinon.stub(shared.validators.input.all, 'add').returns('add-result'); + const result = shared.validators.input.all.setup({}, {}); + assert.equal(result, 'add-result'); + }); + + it('publish delegates to browse', function () { + sinon.stub(shared.validators.input.all, 'browse').returns('browse-result'); + const result = shared.validators.input.all.publish({}, {}); + assert.equal(result, 'browse-result'); + }); + }); +}); diff --git a/ghost/api-framework/vitest.config.mjs b/ghost/api-framework/vitest.config.mjs new file mode 100644 index 00000000000..feda138291d --- /dev/null +++ b/ghost/api-framework/vitest.config.mjs @@ -0,0 +1,12 @@ +import {defineConfig} from 'vitest/config'; + +// Local config so `vitest run` in this package runs only its own tests rather +// than the monorepo root project list. Tests rely on globals (describe/it). +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['test/**/*.test.{js,ts}'], + pool: 'threads' + } +}); diff --git a/ghost/core/package.json b/ghost/core/package.json index 1e4dad5f1c4..ddfb7964a55 100644 --- a/ghost/core/package.json +++ b/ghost/core/package.json @@ -92,7 +92,7 @@ "@slack/webhook": "7.0.9", "@tryghost/adapter-base-cache": "0.1.26", "@tryghost/admin-api-schema": "4.7.5", - "@tryghost/api-framework": "catalog:", + "@tryghost/api-framework": "workspace:*", "@tryghost/bookshelf-plugins": "2.2.4", "@tryghost/color-utils": "catalog:", "@tryghost/config-url-helpers": "1.0.26", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0453019b888..b6eff0ac693 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -72,9 +72,6 @@ catalogs: '@testing-library/react': specifier: 14.3.1 version: 14.3.1 - '@tryghost/api-framework': - specifier: 3.2.4 - version: 3.2.4 '@tryghost/color-utils': specifier: 0.2.19 version: 0.2.19 @@ -222,6 +219,12 @@ catalogs: node-html-markdown: specifier: 2.0.0 version: 2.0.0 + oxfmt: + specifier: 0.54.0 + version: 0.54.0 + oxlint: + specifier: 1.69.0 + version: 1.69.0 postcss: specifier: 8.5.15 version: 8.5.15 @@ -2312,6 +2315,40 @@ importers: specifier: 4.0.2 version: 4.0.2 + ghost/api-framework: + dependencies: + '@tryghost/debug': + specifier: 'catalog:' + version: 2.2.3 + '@tryghost/errors': + specifier: ^1.3.7 + version: 1.3.13 + '@tryghost/promise': + specifier: 2.2.3 + version: 2.2.3 + '@tryghost/tpl': + specifier: 2.2.3 + version: 2.2.3 + '@tryghost/validator': + specifier: 0.2.22 + version: 0.2.22 + lodash: + specifier: 'catalog:' + version: 4.18.1 + devDependencies: + oxfmt: + specifier: 'catalog:' + version: 0.54.0 + oxlint: + specifier: 'catalog:' + version: 1.69.0 + sinon: + specifier: 'catalog:' + version: 22.0.0 + vitest: + specifier: 'catalog:' + version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1(@noble/hashes@1.8.0))(msw@2.14.6(@types/node@25.9.1)(typescript@5.9.3))(vite@7.3.2(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)) + ghost/core: dependencies: '@aws-sdk/client-s3': @@ -2339,8 +2376,8 @@ importers: specifier: 4.7.5 version: 4.7.5 '@tryghost/api-framework': - specifier: 'catalog:' - version: 3.2.4 + specifier: workspace:* + version: link:../api-framework '@tryghost/bookshelf-plugins': specifier: 2.2.4 version: 2.2.4 @@ -6252,6 +6289,250 @@ packages: cpu: [x64] os: [win32] + '@oxfmt/binding-android-arm-eabi@0.54.0': + resolution: {integrity: sha512-NAtpl/SiaeU103e7/OmZw0MvUnsUUopW7hEm/ecegJg7YM0skQaA0IXEZoyTV6NUdiNPupdIUreRqUZTShbn/g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxfmt/binding-android-arm64@0.54.0': + resolution: {integrity: sha512-B4VZfBUlKK1rmMChsssNZbkZjE8+FzG3avMjGgMDwbGxXRoXkoeXiAZ+78Oa+eyDPHvDCiUb4zH/vmCOUSafLQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxfmt/binding-darwin-arm64@0.54.0': + resolution: {integrity: sha512-i02vF75b+ePsQP3tHqSxVYI5S6b8X/xqdPu7/mDHXtpgXLTYXi3jJmfHU0j+dnZZDKaYTx/ioCK7QYJmtiJR2g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxfmt/binding-darwin-x64@0.54.0': + resolution: {integrity: sha512-8VMFvGvooXj7mswkbrhdVZ2/sgiDaBzWpkkbtO+qGDLV4EfJd67nQadHkQC0ZNbaWA9ajXfqI6i7PZLIeDzxEQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxfmt/binding-freebsd-x64@0.54.0': + resolution: {integrity: sha512-0cRHnp43WN1Jrc5s0BdbdKgR1XirdvHy7TAFi3JEsoEVQVJxTXMbpVd76sxXlgRswNMDhVFSJw+y7Eb8mEavFQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxfmt/binding-linux-arm-gnueabihf@0.54.0': + resolution: {integrity: sha512-JyQAk3hK/OEtup7Rw6kZwfdzbKqTVD5jXXb8Xpfay29suwZyfBDMVW/bj4RqEPySYWc6zCp198pOluf8n5uYzg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxfmt/binding-linux-arm-musleabihf@0.54.0': + resolution: {integrity: sha512-qnvLatTpM8vtvjOfcckBOzJjk+n6ce/wwpP8OFeUrD5aNLYcKyWAitwj+Rk3PK9jGanbZvKsJnv14JGQ6XqFdw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxfmt/binding-linux-arm64-gnu@0.54.0': + resolution: {integrity: sha512-SMkhnCzIYZYDk9vw3W/80eeYKmrMpGF0Giuxt4HruFlCH7jEtnPeb3SdQKMfgYi/dgtaf+hZAb5XWPYnxqCQ3w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-arm64-musl@0.54.0': + resolution: {integrity: sha512-QrwJlBFFKnxOd95TAaszpMbZBLzMoYMpGaQTZF8oibacnF5rv8l12IhILhQRPmksWiBqg0YSe2Mnl7ayeJAHSA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-linux-ppc64-gnu@0.54.0': + resolution: {integrity: sha512-WILatiol/TUHTlhod7R09+7Az/XlhKwmY1MHfLZNmewltPWNN/EwxP2rQSHahibZ/cB8gmckEBjBOByD+5bYsQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-riscv64-gnu@0.54.0': + resolution: {integrity: sha512-f05YMG4BH4G8S4ME6UM6fi1MnJ9094mrnvO5Pa4SJlMfWlUM+1/ZWMEF4NnjM7shZAvbHsHRuVYpUo0PHC4P9Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-riscv64-musl@0.54.0': + resolution: {integrity: sha512-UfL+2hj1ClNqcCRT9s8vBU4axDpjxgVxX96G+9DYAYjoc5b0u15CJtn2jgsi9iM+EbGNc5CW1HVRgwVu76UsSA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-linux-s390x-gnu@0.54.0': + resolution: {integrity: sha512-3/XZe931Hka+J6NjnaqJzYpsWWxDTuRdUdwSQHnOuJEgbC+SehIMFJS8hsEjV7LBhVSL2OCnRLvbVW8O97XIyw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-x64-gnu@0.54.0': + resolution: {integrity: sha512-Ik93RlObtu43GbxApafayFjwYE06L6Xr08cSwpBPYbDrLp2ReZx0Jm1DqwRyYRnukUJy+rK2WaEvUQOxdytU9Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-x64-musl@0.54.0': + resolution: {integrity: sha512-yZcakmPlD86CNymknd7KfW+FH+qfbqJH+i0h69CYfV1+KMoVeM9UED+8+TDVoU4haxI0NxY7RPCvRLy3Sqd2Qg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-openharmony-arm64@0.54.0': + resolution: {integrity: sha512-GiVBZNnEZnKu00f1jTg49nomv187d0GQX+O+ocykoLeiaALuEO+swoTehHn9TehTfi7V8H0i0e/yvUjCqnwk1w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxfmt/binding-win32-arm64-msvc@0.54.0': + resolution: {integrity: sha512-J0SSB8Z1Fre2sxRolYcW6Rl1RQmKdQ2hnHyq4YJrfBRiXTObLw4DXnIVraM/UyqGqwOi7yTrQA4VT7DPxlHVKA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxfmt/binding-win32-ia32-msvc@0.54.0': + resolution: {integrity: sha512-O61UDVj8zz6yXJjkHPf05VaMLOXmEF8P5kf/N0W7AQMmd6bcQogl+KJc7rMutKTL524oE9iH32JXZClBFmEQIg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxfmt/binding-win32-x64-msvc@0.54.0': + resolution: {integrity: sha512-1MDpqJPiFqxWtIHas8vkb1VZ7f7eKyTffAwmO8isxQYMaG1OFKsH666BWLeXQLO+IWNfiMssLD55hbR1lIPTqg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@oxlint/binding-android-arm-eabi@1.69.0': + resolution: {integrity: sha512-DKQQbD5cZ/MYfDgDI7YGyGD9FSxABlsBsYFo5p26lloob543tP9+4N3guwdXIYJN+7HSZxLe8YJuwcOWw5qnHg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxlint/binding-android-arm64@1.69.0': + resolution: {integrity: sha512-lEhb+I5pr4inux+JFwfCa1HRq3Os7NirEFQ0H1I35SVEHPm6byX0Ah47xmRha3qi6LAkxUcxViL8o/9PivjzBg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxlint/binding-darwin-arm64@1.69.0': + resolution: {integrity: sha512-GY2YE8lOZW59BW1Ia1y+1gR0XyjrZRvVWHAr8LGeGhYHE0OQJ/7cRKXTkx1P+E9/6awEc3SX8a68SFTjh/E//A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxlint/binding-darwin-x64@1.69.0': + resolution: {integrity: sha512-ax1oZnOjHX3LB7myQyHEaQkDwfLb6str3/nSP6O7EVUviQGNkEGzGV0EqcBJWK+Ufwx0l4xPgyYayurvhAdl2Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxlint/binding-freebsd-x64@1.69.0': + resolution: {integrity: sha512-kHWeHv4g2h8NY+mpCxzCtY4uerMJWTN/TSnNj1CPbakFpHEJ6cTya2wWV0pDSYWOJ2+0UiEbhn3AtXxHtsnKjg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxlint/binding-linux-arm-gnueabihf@1.69.0': + resolution: {integrity: sha512-gq84vM1a1oEehXo27YCDzGVcxPsZDI1yswZwz2Da1/cbnWtrL16XZZnz0G/+gIU8edtHpfjxq5c+vWEHqJfWoQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm-musleabihf@1.69.0': + resolution: {integrity: sha512-kIqEa98JQ0VRyrcncxA417m2AzasqTlD+FyVT1AksjvjkqQcvm7pBWYvoW3/mpyOP2XYvi5nSCCTIe6De1yu5g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm64-gnu@1.69.0': + resolution: {integrity: sha512-j+xYiXozxGWx2cpjCrwwGR4awTxPFsRv3JZrv23RCogEPMc4R7UqjHW47p/RG0aRlbWiROCJ8coUfCwy0dvzHA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-arm64-musl@1.69.0': + resolution: {integrity: sha512-xEPpNppTfN1l/nM7gYSf9iocscu/as+p/7vxkLeLEKnYU+09Dm+5V6IhDYDh+Uz6FajEupWwCLt5SOG0y1PCKg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxlint/binding-linux-ppc64-gnu@1.69.0': + resolution: {integrity: sha512-Ug0+eU7HJBlek+SjklYH62IlOMirEJsdxpihH0kSqX0XdrDD4NdHpQc10fK1JC35yn6KrrcN+uYzlHD38XAf8Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-riscv64-gnu@1.69.0': + resolution: {integrity: sha512-iEyI3GIg0l/s3G4qy2TlaaWKdzj4PJJStwtlocpDTC00PY9hZueotf6OKUj9+yfQh0lrpBW/pLMgTztbAHKJEg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-riscv64-musl@1.69.0': + resolution: {integrity: sha512-NjHjpiI4WIKSMwuoJSZi5VToPeoYOS1FR52HLIDG6lidMdqquusgtODb4iLk0+lb1q3Z0nv2/aPRcC/olmpQGg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxlint/binding-linux-s390x-gnu@1.69.0': + resolution: {integrity: sha512-Ai/prDewoItkDXbp38gwGZi41DycZbUTZJ3UidwoHgQC0/DaqC2TGdtBTQLJ6hSD+SAxASzh8+/eSBPmxfOacA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-x64-gnu@1.69.0': + resolution: {integrity: sha512-Gt3KHgp46mRKz4sJeaASmKvD8ayXookRw07RMf+NowhEztGGDZ7VrXpoW96XuKJLjFukWizOFVNjmYb/u7caNQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-x64-musl@1.69.0': + resolution: {integrity: sha512-7tQhJ2+p/oHv1zcfnjYI7YVzC/7iBaVOfIvFYtxdJ5F45mWgEdrCyXZXZGfiLey5t/5JhOhsaMnnv1kAzckd7g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxlint/binding-openharmony-arm64@1.69.0': + resolution: {integrity: sha512-vmWz6TKp/3hfA4lksR0zHBv/6xuX1jhym6eqOjdH2DXsDDHZWcp2f0KG0VCAnlVbIrjk29G4wAWMXb/Hn1YobA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxlint/binding-win32-arm64-msvc@1.69.0': + resolution: {integrity: sha512-9RExaLgmaw6IoIkU9cTpT71mLfI0xZ86iZH8x518LVsOkjquJMYqb9P7KpC8lgd1t0Dxs41p2pxynq4XR3Ttzw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxlint/binding-win32-ia32-msvc@1.69.0': + resolution: {integrity: sha512-1907kRPF8/PrcIw1E7LMs9JbVrpgnt/MvFdss3an8oDkYNAACXzTntV3t3869ZZhMZxb2AzRGbz1pA/jdFatXA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxlint/binding-win32-x64-msvc@1.69.0': + resolution: {integrity: sha512-w8SOXv3mT9Fi6jY8OXdXCfnvX/3KNLXGNr4HEz2TA7S4Mv/PYAOmpB8y/ge40mxvBMgGNaSaaDwZpAsQn7HtWA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + '@paralleldrive/cuid2@2.3.1': resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} @@ -8694,9 +8975,6 @@ packages: '@tryghost/admin-api-schema@4.7.5': resolution: {integrity: sha512-k8XWP5g6hwpe3DcxhujBVHrDSkUSZ1nedR/TBony3TMbqNCMi4v40HkOhXiryAZ/ENDcmlKj/ZLzYDgG6cb0ww==} - '@tryghost/api-framework@3.2.4': - resolution: {integrity: sha512-Otz6uxGsedTsDqQIw62sQ8pmDpiaLMyvsSvCWUBUc44eMix5LFjZ1FMxKl2jXettj6tfMa04+Qz0p71zW/g/Qg==} - '@tryghost/bookshelf-collision@2.2.4': resolution: {integrity: sha512-2SV1QZkvXiYBXEWSvCeDc5YuHM7fRRcT2N8z/RHpOmDYyWHslmrdKuWb+jJOhIeRmO+oH0pYC4Nk9haQI2dgIg==} @@ -8995,9 +9273,6 @@ packages: '@tryghost/validator@3.1.1': resolution: {integrity: sha512-GIBONNQs7BP7FEKf8XxDk+roLVf86rQle3YWbe4IajqAcHoto952RAARwSAvYOhmqroBfX2++9BAyXEER+Ys9Q==} - '@tryghost/validator@3.1.4': - resolution: {integrity: sha512-xHLD9iek3zeINSy0ZYjVbzQMDgCSGEIzcuT1WUR9+zSBOmQzAXMfvykfn5ppglMroIOUJ4V9OIWcimybQ57BSQ==} - '@tryghost/version@0.1.38': resolution: {integrity: sha512-kVaM7eijFRrBLZLDbmiFxHQ/shmAg5UQ9yM4s9GZN4M3OWY34UHNmjoSOniOHnA5E8fYNA8A3IFmS3FW910xYg==} @@ -14772,7 +15047,7 @@ packages: csstype: ^3.0.10 google-caja-bower@https://codeload.github.com/acburdine/google-caja-bower/tar.gz/275cb75249f038492094a499756a73719ae071fd: - resolution: {gitHosted: true, tarball: https://codeload.github.com/acburdine/google-caja-bower/tar.gz/275cb75249f038492094a499756a73719ae071fd} + resolution: {gitHosted: true, integrity: sha512-mmCXdxGKGKDznjgkNzVqzTslaldslk5KMb/A7l8rxWnqyxzwsdPhuBJ6oT1Kh/Y3k4jN54ISee/2AgjFyCBxYw==, tarball: https://codeload.github.com/acburdine/google-caja-bower/tar.gz/275cb75249f038492094a499756a73719ae071fd} version: 6011.0.0 gopd@1.2.0: @@ -16165,7 +16440,7 @@ packages: engines: {node: '>= 0.6'} keymaster@https://codeload.github.com/madrobby/keymaster/tar.gz/f8f43ddafad663b505dc0908e72853bcf8daea49: - resolution: {gitHosted: true, tarball: https://codeload.github.com/madrobby/keymaster/tar.gz/f8f43ddafad663b505dc0908e72853bcf8daea49} + resolution: {gitHosted: true, integrity: sha512-/WVovQslVEqPGNoD97TbqNHuCDPYu2v4/ggrZj0a+9PVPw3Rud4Ut2K7fOi0kMqzoJINkgP68e9m09Al/wFZ8g==, tarball: https://codeload.github.com/madrobby/keymaster/tar.gz/f8f43ddafad663b505dc0908e72853bcf8daea49} version: 1.6.3 keypair@1.0.4: @@ -17906,6 +18181,32 @@ packages: oxc-resolver@11.20.0: resolution: {integrity: sha512-CblytBiV/a/ZXY34dsVU2NxhIOxMXst8CvDCtyBelVITgd7PLrKzbEbA6oKLdPjvDKDzCiW48qzmzZ+mYaqn+g==} + oxfmt@0.54.0: + resolution: {integrity: sha512-DjnMwn7smSLF+Mc2+pRItnuPftm/dkUFpY/d4+33y9TfKrsHZo8GLhmUg9BrOIUEy94Rlom1Q11N6vuhE+e0oQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + svelte: ^5.0.0 + vite-plus: '*' + peerDependenciesMeta: + svelte: + optional: true + vite-plus: + optional: true + + oxlint@1.69.0: + resolution: {integrity: sha512-ypZkK/aDc5NQV8zIR6s2H2Tl3aNW8FmJ1m9+2qsaYuRenl8vgnHNCGwTHviWJdUQzglOlHFchgopdtGhSy17Rw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + oxlint-tsgolint: '>=0.22.1' + vite-plus: '*' + peerDependenciesMeta: + oxlint-tsgolint: + optional: true + vite-plus: + optional: true + p-cancelable@2.1.1: resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==} engines: {node: '>=8'} @@ -20908,6 +21209,10 @@ packages: resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} engines: {node: '>=12.0.0'} + tinypool@2.1.0: + resolution: {integrity: sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==} + engines: {node: ^20.0.0 || >=22.0.0} + tinyrainbow@2.0.0: resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} engines: {node: '>=14.0.0'} @@ -25819,6 +26124,120 @@ snapshots: '@oxc-resolver/binding-win32-x64-msvc@11.20.0': optional: true + '@oxfmt/binding-android-arm-eabi@0.54.0': + optional: true + + '@oxfmt/binding-android-arm64@0.54.0': + optional: true + + '@oxfmt/binding-darwin-arm64@0.54.0': + optional: true + + '@oxfmt/binding-darwin-x64@0.54.0': + optional: true + + '@oxfmt/binding-freebsd-x64@0.54.0': + optional: true + + '@oxfmt/binding-linux-arm-gnueabihf@0.54.0': + optional: true + + '@oxfmt/binding-linux-arm-musleabihf@0.54.0': + optional: true + + '@oxfmt/binding-linux-arm64-gnu@0.54.0': + optional: true + + '@oxfmt/binding-linux-arm64-musl@0.54.0': + optional: true + + '@oxfmt/binding-linux-ppc64-gnu@0.54.0': + optional: true + + '@oxfmt/binding-linux-riscv64-gnu@0.54.0': + optional: true + + '@oxfmt/binding-linux-riscv64-musl@0.54.0': + optional: true + + '@oxfmt/binding-linux-s390x-gnu@0.54.0': + optional: true + + '@oxfmt/binding-linux-x64-gnu@0.54.0': + optional: true + + '@oxfmt/binding-linux-x64-musl@0.54.0': + optional: true + + '@oxfmt/binding-openharmony-arm64@0.54.0': + optional: true + + '@oxfmt/binding-win32-arm64-msvc@0.54.0': + optional: true + + '@oxfmt/binding-win32-ia32-msvc@0.54.0': + optional: true + + '@oxfmt/binding-win32-x64-msvc@0.54.0': + optional: true + + '@oxlint/binding-android-arm-eabi@1.69.0': + optional: true + + '@oxlint/binding-android-arm64@1.69.0': + optional: true + + '@oxlint/binding-darwin-arm64@1.69.0': + optional: true + + '@oxlint/binding-darwin-x64@1.69.0': + optional: true + + '@oxlint/binding-freebsd-x64@1.69.0': + optional: true + + '@oxlint/binding-linux-arm-gnueabihf@1.69.0': + optional: true + + '@oxlint/binding-linux-arm-musleabihf@1.69.0': + optional: true + + '@oxlint/binding-linux-arm64-gnu@1.69.0': + optional: true + + '@oxlint/binding-linux-arm64-musl@1.69.0': + optional: true + + '@oxlint/binding-linux-ppc64-gnu@1.69.0': + optional: true + + '@oxlint/binding-linux-riscv64-gnu@1.69.0': + optional: true + + '@oxlint/binding-linux-riscv64-musl@1.69.0': + optional: true + + '@oxlint/binding-linux-s390x-gnu@1.69.0': + optional: true + + '@oxlint/binding-linux-x64-gnu@1.69.0': + optional: true + + '@oxlint/binding-linux-x64-musl@1.69.0': + optional: true + + '@oxlint/binding-openharmony-arm64@1.69.0': + optional: true + + '@oxlint/binding-win32-arm64-msvc@1.69.0': + optional: true + + '@oxlint/binding-win32-ia32-msvc@1.69.0': + optional: true + + '@oxlint/binding-win32-x64-msvc@1.69.0': + optional: true + '@paralleldrive/cuid2@2.3.1': dependencies: '@noble/hashes': 1.8.0 @@ -28478,17 +28897,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@tryghost/api-framework@3.2.4': - dependencies: - '@tryghost/debug': 2.2.3 - '@tryghost/errors': 1.3.13 - '@tryghost/promise': 2.2.3 - '@tryghost/tpl': 2.2.3 - '@tryghost/validator': 3.1.4 - lodash: 4.18.1 - transitivePeerDependencies: - - supports-color - '@tryghost/bookshelf-collision@2.2.4': dependencies: '@tryghost/errors': 1.3.13 @@ -29175,16 +29583,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@tryghost/validator@3.1.4': - dependencies: - '@tryghost/errors': 1.3.13 - '@tryghost/tpl': 2.2.3 - lodash: 4.18.1 - moment-timezone: 0.5.45 - validator: 13.15.35 - transitivePeerDependencies: - - supports-color - '@tryghost/version@0.1.38': dependencies: '@tryghost/root-utils': 0.3.38 @@ -41852,6 +42250,52 @@ snapshots: '@oxc-resolver/binding-win32-arm64-msvc': 11.20.0 '@oxc-resolver/binding-win32-x64-msvc': 11.20.0 + oxfmt@0.54.0: + dependencies: + tinypool: 2.1.0 + optionalDependencies: + '@oxfmt/binding-android-arm-eabi': 0.54.0 + '@oxfmt/binding-android-arm64': 0.54.0 + '@oxfmt/binding-darwin-arm64': 0.54.0 + '@oxfmt/binding-darwin-x64': 0.54.0 + '@oxfmt/binding-freebsd-x64': 0.54.0 + '@oxfmt/binding-linux-arm-gnueabihf': 0.54.0 + '@oxfmt/binding-linux-arm-musleabihf': 0.54.0 + '@oxfmt/binding-linux-arm64-gnu': 0.54.0 + '@oxfmt/binding-linux-arm64-musl': 0.54.0 + '@oxfmt/binding-linux-ppc64-gnu': 0.54.0 + '@oxfmt/binding-linux-riscv64-gnu': 0.54.0 + '@oxfmt/binding-linux-riscv64-musl': 0.54.0 + '@oxfmt/binding-linux-s390x-gnu': 0.54.0 + '@oxfmt/binding-linux-x64-gnu': 0.54.0 + '@oxfmt/binding-linux-x64-musl': 0.54.0 + '@oxfmt/binding-openharmony-arm64': 0.54.0 + '@oxfmt/binding-win32-arm64-msvc': 0.54.0 + '@oxfmt/binding-win32-ia32-msvc': 0.54.0 + '@oxfmt/binding-win32-x64-msvc': 0.54.0 + + oxlint@1.69.0: + optionalDependencies: + '@oxlint/binding-android-arm-eabi': 1.69.0 + '@oxlint/binding-android-arm64': 1.69.0 + '@oxlint/binding-darwin-arm64': 1.69.0 + '@oxlint/binding-darwin-x64': 1.69.0 + '@oxlint/binding-freebsd-x64': 1.69.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.69.0 + '@oxlint/binding-linux-arm-musleabihf': 1.69.0 + '@oxlint/binding-linux-arm64-gnu': 1.69.0 + '@oxlint/binding-linux-arm64-musl': 1.69.0 + '@oxlint/binding-linux-ppc64-gnu': 1.69.0 + '@oxlint/binding-linux-riscv64-gnu': 1.69.0 + '@oxlint/binding-linux-riscv64-musl': 1.69.0 + '@oxlint/binding-linux-s390x-gnu': 1.69.0 + '@oxlint/binding-linux-x64-gnu': 1.69.0 + '@oxlint/binding-linux-x64-musl': 1.69.0 + '@oxlint/binding-openharmony-arm64': 1.69.0 + '@oxlint/binding-win32-arm64-msvc': 1.69.0 + '@oxlint/binding-win32-ia32-msvc': 1.69.0 + '@oxlint/binding-win32-x64-msvc': 1.69.0 + p-cancelable@2.1.1: {} p-cancelable@3.0.0: {} @@ -45499,6 +45943,8 @@ snapshots: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 + tinypool@2.1.0: {} + tinyrainbow@2.0.0: {} tinyrainbow@3.1.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 74986f872ab..10858a61f36 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -47,7 +47,6 @@ catalog: '@tanstack/react-query': 4.44.0 '@testing-library/jest-dom': 6.9.1 '@testing-library/react': 14.3.1 - '@tryghost/api-framework': 3.2.4 '@tryghost/color-utils': 0.2.19 '@tryghost/custom-fonts': 1.0.11 '@tryghost/debug': 2.2.3 @@ -98,6 +97,8 @@ catalog: mocha: 11.7.6 msw: 2.14.6 node-html-markdown: 2.0.0 + oxfmt: 0.54.0 + oxlint: 1.69.0 postcss: 8.5.15 preact: ^10.29.2 react: 18.3.1 diff --git a/vitest.config.mjs b/vitest.config.mjs index ff2bb0f1ec2..168647b065b 100644 --- a/vitest.config.mjs +++ b/vitest.config.mjs @@ -14,6 +14,7 @@ export default defineConfig({ test: { projects: [ 'ghost/core', + 'ghost/api-framework', 'apps/*', '!apps/admin-x-activitypub', '!apps/signup-form'