Added /tiers API to Admin API (#14200)

refs https://github.com/TryGhost/Team/issues/1313

Rather than removing the /products API we're adding a /tiers API as
a first step towards renaming "products" to "tiers". The initial idea was
to alias the URL's but out API framework doesn't easily allow for this so
we've duplicated it instead.
This commit is contained in:
Fabien 'egg' O'Carroll 2022-02-23 17:00:18 +02:00 committed by GitHub
parent 33da584161
commit 694721cbea
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 592 additions and 5 deletions

View File

@ -93,6 +93,10 @@ module.exports = {
return shared.pipeline(require('./products'), localUtils);
},
get tiers() {
return shared.pipeline(require('./tiers'), localUtils);
},
get memberSigninUrls() {
return shared.pipeline(require('./memberSigninUrls.js'), localUtils);
},

View File

@ -0,0 +1,123 @@
const errors = require('@tryghost/errors');
const membersService = require('../../services/members');
const tpl = require('@tryghost/tpl');
const allowedIncludes = ['monthly_price', 'yearly_price', 'benefits'];
const messages = {
productNotFound: 'Tier not found.'
};
module.exports = {
docName: 'tiers',
browse: {
options: [
'limit',
'fields',
'include',
'filter',
'order',
'debug',
'page'
],
permissions: {
docName: 'products'
},
validation: {
options: {
include: {
values: allowedIncludes
}
}
},
async query(frame) {
const page = await membersService.api.productRepository.list(frame.options);
return page;
}
},
read: {
options: [
'include'
],
headers: {},
data: [
'id'
],
validation: {
options: {
include: {
values: allowedIncludes
}
}
},
permissions: true,
async query(frame) {
const model = await membersService.api.productRepository.get(frame.data, frame.options);
if (!model) {
throw new errors.NotFoundError({
message: tpl(messages.productNotFound)
});
}
return model;
}
},
add: {
statusCode: 201,
headers: {
cacheInvalidate: true
},
validation: {
data: {
name: {required: true}
}
},
permissions: {
docName: 'products'
},
async query(frame) {
const model = await membersService.api.productRepository.create(
frame.data,
frame.options
);
return model;
}
},
edit: {
statusCode: 200,
options: [
'id'
],
headers: {},
validation: {
options: {
id: {
required: true
}
}
},
permissions: {
docName: 'products'
},
async query(frame) {
const model = await membersService.api.productRepository.update(
frame.data,
frame.options
);
if (model.wasChanged()) {
this.headers.cacheInvalidate = true;
} else {
this.headers.cacheInvalidate = false;
}
return model;
}
}
};

View File

@ -43,6 +43,10 @@ module.exports = {
return require('./products');
},
get tiers() {
return require('./tiers');
},
get webhooks() {
return require('./webhooks');
}

View File

@ -0,0 +1,36 @@
module.exports = {
all(_apiConfig, frame) {
if (!frame.options.withRelated) {
return;
}
frame.options.withRelated = frame.options.withRelated.map((relation) => {
if (relation === 'stripe_prices') {
return 'stripePrices';
}
if (relation === 'monthly_price') {
return 'monthlyPrice';
}
if (relation === 'yearly_price') {
return 'yearlyPrice';
}
return relation;
});
},
add(_apiConfig, frame) {
if (frame.data.products) {
frame.data = frame.data.products[0];
return;
}
frame.data = frame.data.tiers[0];
},
edit(_apiConfig, frame) {
if (frame.data.products) {
frame.data = frame.data.products[0];
return;
}
frame.data = frame.data.tiers[0];
}
};

View File

@ -73,6 +73,10 @@ module.exports = {
return require('./products');
},
get tiers() {
return require('./tiers');
},
get member_signin_urls() {
return require('./member-signin_urls');
},

View File

@ -0,0 +1,210 @@
//@ts-check
const debug = require('@tryghost/debug')('api:canary:utils:serializers:output:tiers');
const _ = require('lodash');
const allowedIncludes = ['monthly_price', 'yearly_price'];
module.exports = {
browse: createSerializer('browse', paginatedTiers),
read: createSerializer('read', singleTier),
edit: createSerializer('edit', singleTier),
add: createSerializer('add', singleTier)
};
/**
* @template PageMeta
*
* @param {{data: import('bookshelf').Model[], meta: PageMeta}} page
* @param {APIConfig} _apiConfig
* @param {Frame} frame
*
* @returns {{tiers: SerializedTier[], meta: PageMeta}}
*/
function paginatedTiers(page, _apiConfig, frame) {
const requestedQueryIncludes = frame.original && frame.original.query && frame.original.query.include && frame.original.query.include.split(',') || [];
const requestedOptionsIncludes = frame.original && frame.original.options && frame.original.options.include || [];
return {
tiers: page.data.map((model) => {
return cleanIncludes(
allowedIncludes,
requestedQueryIncludes.concat(requestedOptionsIncludes),
serializeTier(model, frame.options, frame.apiType)
);
}),
meta: page.meta
};
}
/**
* @param {import('bookshelf').Model} model
* @param {APIConfig} _apiConfig
* @param {Frame} frame
*
* @returns {{tiers: SerializedTier[]}}
*/
function singleTier(model, _apiConfig, frame) {
const requestedQueryIncludes = frame.original && frame.original.query && frame.original.query.include && frame.original.query.include.split(',') || [];
const requestedOptionsIncludes = frame.original && frame.original.options && frame.original.options.include || [];
return {
tiers: [
cleanIncludes(
allowedIncludes,
requestedQueryIncludes.concat(requestedOptionsIncludes),
serializeTier(model, frame.options, frame.apiType)
)
]
};
}
/**
* @param {import('bookshelf').Model} tier
* @param {object} options
* @param {'content'|'admin'} apiType
*
* @returns {SerializedTier}
*/
function serializeTier(tier, options, apiType) {
const json = tier.toJSON(options);
const hideStripeData = apiType === 'content';
const serialized = {
id: json.id,
name: json.name,
description: json.description,
slug: json.slug,
active: json.active,
type: json.type,
welcome_page_url: json.welcome_page_url,
created_at: json.created_at,
updated_at: json.updated_at,
stripe_prices: json.stripePrices ? json.stripePrices.map(price => serializeStripePrice(price, hideStripeData)) : null,
monthly_price: serializeStripePrice(json.monthlyPrice, hideStripeData),
yearly_price: serializeStripePrice(json.yearlyPrice, hideStripeData),
benefits: json.benefits || null
};
return serialized;
}
/**
* @param {object} data
* @param {boolean} hideStripeData
*
* @returns {StripePrice}
*/
function serializeStripePrice(data, hideStripeData) {
if (_.isEmpty(data)) {
return null;
}
const price = {
id: data.id,
stripe_tier_id: data.stripe_product_id,
stripe_price_id: data.stripe_price_id,
active: data.active,
nickname: data.nickname,
description: data.description,
currency: data.currency,
amount: data.amount,
type: data.type,
interval: data.interval,
created_at: data.created_at,
updated_at: data.updated_at
};
if (hideStripeData) {
delete price.stripe_price_id;
delete price.stripe_tier_id;
}
return price;
}
/**
* @template Data
*
* @param {string[]} allowed
* @param {string[]} requested
* @param {Data & Object<string, any>} data
*
* @returns {Data}
*/
function cleanIncludes(allowed, requested, data) {
const cleaned = {
...data
};
for (const include of allowed) {
if (!requested.includes(include)) {
delete cleaned[include];
}
}
return cleaned;
}
/**
* @template Data
* @template Response
* @param {string} debugString
* @param {(data: Data, apiConfig: APIConfig, frame: Frame) => Response} serialize - A function to serialize the data into an object suitable for API response
*
* @returns {(data: Data, apiConfig: APIConfig, frame: Frame) => void}
*/
function createSerializer(debugString, serialize) {
return function serializer(data, apiConfig, frame) {
debug(debugString);
const response = serialize(data, apiConfig, frame);
frame.response = response;
};
}
/**
* @typedef {Object} SerializedTier
* @prop {string} id
* @prop {string} name
* @prop {string} slug
* @prop {string} description
* @prop {boolean} active
* @prop {string} type
* @prop {string} welcome_page_url
* @prop {Date} created_at
* @prop {Date} updated_at
* @prop {StripePrice} [monthly_price]
* @prop {StripePrice} [yearly_price]
* @prop {Benefit[]} [benefits]
*/
/**
* @typedef {object} Benefit
* @prop {string} id
* @prop {string} name
* @prop {string} slug
* @prop {Date} created_at
* @prop {Date} updated_at
*/
/**
* @typedef {object} StripePrice
* @prop {string} id
* @prop {string|null} stripe_tier_id
* @prop {string|null} stripe_price_id
* @prop {boolean} active
* @prop {string} nickname
* @prop {string} description
* @prop {string} currency
* @prop {number} amount
* @prop {'recurring'|'one-time'} type
* @prop {'day'|'week'|'month'|'year'} interval
* @prop {Date} created_at
* @prop {Date} updated_at
*/
/**
* @typedef {Object} APIConfig
* @prop {string} docName
* @prop {string} method
*/
/**
* @typedef {Object<string, any>} Frame
* @prop {Object} options
*/

View File

@ -27,6 +27,10 @@ module.exports = {
return require('./members');
},
get tiers() {
return require('./tiers');
},
get media() {
return require('./media');
},

View File

@ -0,0 +1,6 @@
const jsonSchema = require('../utils/json-schema');
module.exports = {
add: jsonSchema.validate,
edit: jsonSchema.validate
};

View File

@ -88,11 +88,18 @@ module.exports = function apiRoutes() {
router.del('/tags/:id', mw.authAdminApi, http(api.tags.destroy));
// Products
// TODO Remove
router.get('/products', mw.authAdminApi, http(api.products.browse));
router.post('/products', mw.authAdminApi, http(api.products.add));
router.get('/products/:id', mw.authAdminApi, http(api.products.read));
router.put('/products/:id', mw.authAdminApi, http(api.products.edit));
// Tiers
router.get('/tiers', mw.authAdminApi, http(api.tiers.browse));
router.post('/tiers', mw.authAdminApi, http(api.tiers.add));
router.get('/tiers/:id', mw.authAdminApi, http(api.tiers.read));
router.put('/tiers/:id', mw.authAdminApi, http(api.tiers.edit));
// ## Members
router.get('/members', mw.authAdminApi, http(api.members.browse));
router.post('/members', mw.authAdminApi, http(api.members.add));

View File

@ -58,7 +58,7 @@
"@nexes/nql": "0.6.0",
"@sentry/node": "6.17.9",
"@tryghost/adapter-manager": "0.2.27",
"@tryghost/admin-api-schema": "2.9.0",
"@tryghost/admin-api-schema": "2.10.0",
"@tryghost/bookshelf-plugins": "0.3.9",
"@tryghost/bootstrap-socket": "0.2.16",
"@tryghost/color-utils": "0.1.7",

View File

@ -0,0 +1,95 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Tiers API Can browse Tiers 1: [body] 1`] = `
Object {
"meta": Object {
"pagination": Object {
"limit": 15,
"next": null,
"page": 1,
"pages": 1,
"prev": null,
"total": 2,
},
},
"tiers": Array [
Object {
"active": true,
"benefits": null,
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"description": null,
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"name": "Free",
"slug": "free",
"stripe_prices": null,
"type": "free",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"welcome_page_url": "/welcome-free",
},
Object {
"active": true,
"benefits": null,
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"description": null,
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"name": "Default Product",
"slug": "default-product",
"stripe_prices": null,
"type": "paid",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"welcome_page_url": "/welcome-paid",
},
],
}
`;
exports[`Tiers API Errors when price is negative 1: [body] 1`] = `
Object {
"errors": Array [
Object {
"code": null,
"context": "Tier prices must not be negative",
"details": null,
"help": null,
"id": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
"message": "Validation error, cannot save tier.",
"property": null,
"type": "ValidationError",
},
],
}
`;
exports[`Tiers API Errors when price is non-integer 1: [body] 1`] = `
Object {
"errors": Array [
Object {
"code": null,
"context": "Tier prices must be an integer.",
"details": null,
"help": null,
"id": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
"message": "Validation error, cannot save tier.",
"property": null,
"type": "ValidationError",
},
],
}
`;
exports[`Tiers API Errors when price is too large 1: [body] 1`] = `
Object {
"errors": Array [
Object {
"code": null,
"context": "Tier prices may not exceed 999999.99",
"details": null,
"help": null,
"id": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
"message": "Validation error, cannot save tier.",
"property": null,
"type": "ValidationError",
},
],
}
`;

View File

@ -0,0 +1,94 @@
const {
agentProvider,
fixtureManager,
mockManager,
matchers
} = require('../../utils/e2e-framework');
describe('Tiers API', function () {
let agent;
before(async function () {
agent = await agentProvider.getAdminAPIAgent();
await fixtureManager.init('members');
await agent.loginAsOwner();
});
beforeEach(function () {
mockManager.mockLabsEnabled('multipleProducts');
});
afterEach(function () {
mockManager.restore();
});
it('Can browse Tiers', async function () {
await agent
.get('/tiers/')
.expectStatus(200)
.matchBodySnapshot({
tiers: Array(2).fill({
id: matchers.anyObjectId,
created_at: matchers.anyDate,
updated_at: matchers.anyDate
})
});
});
it('Errors when price is non-integer', async function () {
const tier = {
name: 'Blah',
monthly_price: {
amount: 99.99
}
};
await agent
.post('/tiers/')
.body({tiers: [tier]})
.expectStatus(422)
.matchBodySnapshot({
errors: [{
id: matchers.anyUuid
}]
});
});
it('Errors when price is negative', async function () {
const tier = {
name: 'Blah',
monthly_price: {
amount: -100
}
};
await agent
.post('/tiers/')
.body({tiers: [tier]})
.expectStatus(422)
.matchBodySnapshot({
errors: [{
id: matchers.anyUuid
}]
});
});
it('Errors when price is too large', async function () {
const tier = {
name: 'Blah',
monthly_price: {
amount: Number.MAX_SAFE_INTEGER
}
};
await agent
.post('/tiers/')
.body({tiers: [tier]})
.expectStatus(422)
.matchBodySnapshot({
errors: [{
id: matchers.anyUuid
}]
});
});
});

View File

@ -1744,10 +1744,10 @@
dependencies:
"@tryghost/errors" "^1.2.1"
"@tryghost/admin-api-schema@2.9.0":
version "2.9.0"
resolved "https://registry.yarnpkg.com/@tryghost/admin-api-schema/-/admin-api-schema-2.9.0.tgz#8dbc9e1f320d94c1f404126f3a907e6984a1b28e"
integrity sha512-Tv/Obr40IU5k2XmUrN+K7kSub2VQMMnjLQZfwAiMRvUAahKa/rYKunIB1UKgTjd57WdHJncYRbO9UcPoRLn+nQ==
"@tryghost/admin-api-schema@2.10.0":
version "2.10.0"
resolved "https://registry.yarnpkg.com/@tryghost/admin-api-schema/-/admin-api-schema-2.10.0.tgz#5d0966f3d937251070933314d64bd1486e6c93f5"
integrity sha512-EATkl1UYNN8Xmpk7xizjjrcmZW6B97z9yAtBDt+4lPEDpdZoCd/yVau640/Y5q257lWjaXq0CeQ6O9kpN7WwJQ==
dependencies:
"@tryghost/errors" "^0.2.10"
lodash "^4.17.11"