0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-03-11 02:12:21 -05:00

Replaced Products with Tiers API endpoints

ref: https://github.com/TryGhost/Team/issues/1145
ref: 8f8b7e7364

- The /products/ endpoint was replaced with /tiers/ some time ago but we didn't finish the switch
- The work is complete now, so can remove the endpoint entirely and cleanup remaining usages
This commit is contained in:
Hannah Wolfe 2022-05-12 15:44:06 +01:00
parent a58ac016be
commit 5cc7a54edc
No known key found for this signature in database
GPG key ID: AB586C3B5AE5C037
14 changed files with 14 additions and 560 deletions

View file

@ -85,10 +85,6 @@ module.exports = {
return shared.pipeline(require('./offers'), localUtils);
},
get products() {
return shared.pipeline(require('./products'), localUtils);
},
get tiers() {
return shared.pipeline(require('./tiers'), localUtils);
},
@ -209,10 +205,6 @@ module.exports = {
return shared.pipeline(require('./authors-public'), localUtils, 'content');
},
get productsPublic() {
return shared.pipeline(require('./products-public'), localUtils, 'content');
},
get tiersPublic() {
return shared.pipeline(require('./tiers-public'), localUtils, 'content');
},

View file

@ -1,34 +0,0 @@
// NOTE: We must not cache references to membersService.api
// as it is a getter and may change during runtime.
const membersService = require('../../services/members');
const allowedIncludes = ['stripe_prices', 'monthly_price', 'yearly_price', 'benefits'];
module.exports = {
docName: 'products',
browse: {
options: [
'limit',
'fields',
'include',
'filter',
'order',
'debug',
'page'
],
permissions: true,
validation: {
options: {
include: {
values: allowedIncludes
}
}
},
async query(frame) {
const page = await membersService.api.productRepository.list(frame.options);
return page;
}
}
};

View file

@ -1,116 +0,0 @@
// NOTE: We must not cache references to membersService.api
// as it is a getter and may change during runtime.
const errors = require('@tryghost/errors');
const membersService = require('../../services/members');
const tpl = require('@tryghost/tpl');
const allowedIncludes = ['stripe_prices', 'monthly_price', 'yearly_price', 'benefits'];
const messages = {
productNotFound: 'Product not found.'
};
module.exports = {
docName: 'products',
browse: {
options: [
'limit',
'fields',
'include',
'filter',
'order',
'debug',
'page'
],
permissions: true,
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: true,
async query(frame) {
const model = await membersService.api.productRepository.create(
frame.data,
frame.options
);
return model;
}
},
edit: {
statusCode: 200,
options: [
'id'
],
headers: {
cacheInvalidate: true
},
validation: {
options: {
id: {
required: true
}
}
},
permissions: true,
async query(frame) {
const model = await membersService.api.productRepository.update(
frame.data,
frame.options
);
return model;
}
}
};

View file

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

View file

@ -1,28 +0,0 @@
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) {
frame.data = frame.data.products[0];
},
edit(_apiConfig, frame) {
frame.data = frame.data.products[0];
}
};

View file

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

View file

@ -1,213 +0,0 @@
//@ts-check
const debug = require('@tryghost/debug')('api:canary:utils:serializers:output:products');
const _ = require('lodash');
const utils = require('../../../../shared/utils');
const allowedIncludes = ['stripe_prices', 'monthly_price', 'yearly_price'];
module.exports = {
browse: createSerializer('browse', paginatedProducts),
read: createSerializer('read', singleProduct),
edit: createSerializer('edit', singleProduct),
add: createSerializer('add', singleProduct)
};
/**
* @template PageMeta
*
* @param {{data: import('bookshelf').Model[], meta: PageMeta}} page
* @param {APIConfig} _apiConfig
* @param {Frame} frame
*
* @returns {{products: SerializedProduct[], meta: PageMeta}}
*/
function paginatedProducts(page, _apiConfig, frame) {
const requestedQueryIncludes = frame.original && frame.original.query && frame.original.query.include && frame.original.query.include.split(',') || [];
const requestedOptionsIncludes = utils.options.trimAndLowerCase(frame.original && frame.original.options && frame.original.options.include || []);
return {
products: page.data.map((model) => {
return cleanIncludes(
allowedIncludes,
requestedQueryIncludes.concat(requestedOptionsIncludes),
serializeProduct(model, frame.options, frame.apiType)
);
}),
meta: page.meta
};
}
/**
* @param {import('bookshelf').Model} model
* @param {APIConfig} _apiConfig
* @param {Frame} frame
*
* @returns {{products: SerializedProduct[]}}
*/
function singleProduct(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 {
products: [
cleanIncludes(
allowedIncludes,
requestedQueryIncludes.concat(requestedOptionsIncludes),
serializeProduct(model, frame.options, frame.apiType)
)
]
};
}
/**
* @param {import('bookshelf').Model} product
* @param {object} options
* @param {'content'|'admin'} apiType
*
* @returns {SerializedProduct}
*/
function serializeProduct(product, options, apiType) {
const json = product.toJSON(options);
const hideStripeData = apiType === 'content';
const serialized = {
id: json.id,
name: json.name,
description: json.description,
slug: json.slug,
active: json.active,
visibility: json.visibility,
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_product_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_product_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} SerializedProduct
* @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[]} [stripe_prices]
* @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_product_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

@ -86,13 +86,6 @@ module.exports = function apiRoutes() {
router.put('/tags/:id', mw.authAdminApi, http(api.tags.edit));
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));

View file

@ -32,10 +32,9 @@ module.exports = function apiRoutes() {
// ## Settings
router.get('/settings', mw.authenticatePublic, http(api.publicSettings.browse));
router.get('/products', mw.authenticatePublic, http(api.productsPublic.browse));
router.get('/tiers', mw.authenticatePublic, http(api.tiersPublic.browse));
// ## Members
router.get('/newsletters', mw.authenticatePublic, http(api.newslettersPublic.browse));
router.get('/tiers', mw.authenticatePublic, http(api.tiersPublic.browse));
router.get('/offers/:id', mw.authenticatePublic, http(api.offersPublic.read));
return router;

View file

@ -128,7 +128,7 @@ describe('Pages API', function () {
});
it('Can include specific tier for page with tiers visibility', async function () {
const res = await request.get(localUtils.API.getApiQuery('products/'))
const res = await request.get(localUtils.API.getApiQuery('tiers/'))
.set('Origin', config.get('url'))
.expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.private)
@ -136,7 +136,7 @@ describe('Pages API', function () {
const jsonResponse = res.body;
const paidTier = jsonResponse.products.find(p => p.type === 'paid');
const paidTier = jsonResponse.tiers.find(p => p.type === 'paid');
const tiersPage = testUtils.DataGenerator.forKnex.createPost({
type: 'page',
@ -199,15 +199,15 @@ describe('Pages API', function () {
.set('Origin', config.get('url'))
.expect(200);
const resProducts = await request
.get(localUtils.API.getApiQuery(`products/`))
const resTiers = await request
.get(localUtils.API.getApiQuery(`tiers/`))
.set('Origin', config.get('url'))
.expect(200);
const products = resProducts.body.products;
const tiers = resTiers.body.tiers;
page.updated_at = res.body.pages[0].updated_at;
page.visibility = 'tiers';
const paidTiers = products.filter((p) => {
const paidTiers = tiers.filter((p) => {
return p.type === 'paid';
}).map((product) => {
return product;

View file

@ -354,7 +354,7 @@ describe('Posts API', function () {
});
it('Can include specific tier for post with tiers visibility', async function () {
const res = await request.get(localUtils.API.getApiQuery('products/'))
const res = await request.get(localUtils.API.getApiQuery('tiers/'))
.set('Origin', config.get('url'))
.expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.private)
@ -362,7 +362,7 @@ describe('Posts API', function () {
const jsonResponse = res.body;
const paidTier = jsonResponse.products.find(p => p.type === 'paid');
const paidTier = jsonResponse.tiers.find(p => p.type === 'paid');
const tiersPost = testUtils.DataGenerator.forKnex.createPost({
slug: 'thou-shalt-be-for-specific-tiers',

View file

@ -1,131 +0,0 @@
const should = require('should');
const sinon = require('sinon');
const {agentProvider, fixtureManager, mockManager} = 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.mockLabsDisabled('multipleProducts');
const stripeService = require('../../../core/server/services/stripe');
stripeService.api._configured = true;
mockManager.mockStripe();
sinon
.stub(stripeService.api, 'createProduct')
.resolves({
id: 'prod_LFPlH9BDDwXcZ1'
});
sinon
.stub(stripeService.api, 'createPrice')
.resolves({
id: 'price_1KYpK92eZvKYlo2C86IrYSPM',
currency: 'usd',
nickname: null,
unit_amount: 299
});
});
afterEach(function () {
mockManager.restore();
const stripeService = require('../../../core/server/services/stripe');
stripeService.api._configured = false;
});
it('Errors when price is non-integer', async function () {
const tier = {
name: 'Blah',
monthly_price: {
amount: 99.99
}
};
await agent
.post('/products/')
.body({products: [tier]})
.expectStatus(422);
});
it('Errors when price is negative', async function () {
const tier = {
name: 'Blah',
monthly_price: {
amount: -100
}
};
await agent
.post('/products/')
.body({products: [tier]})
.expectStatus(422);
});
it('Errors when price is too large', async function () {
const tier = {
name: 'Blah',
monthly_price: {
amount: Number.MAX_SAFE_INTEGER
}
};
await agent
.post('/products/')
.body({products: [tier]})
.expectStatus(422);
});
it('Create a new tier with benefits', async function () {
const tier = {
name: 'Blah',
monthly_price: {
amount: 10
},
benefits: [{
name: 'This is a benefit'
}]
};
await agent
.post('/products/')
.body({products: [tier]})
.expectStatus(201);
});
it('Errors when a benefit has an empty name', async function () {
const tier = {
name: 'Blah',
monthly_price: {
amount: 10
},
benefits: [{
name: ''
}]
};
await agent
.post('/products/')
.body({products: [tier]})
.expectStatus(422);
});
it('Errors when a product is edited with a benefit that has an empty name', async function () {
const tier = {
benefits: [{
name: ''
}]
};
await agent
.put('/products/' + fixtureManager.get('products', 0).id)
.body({products: [tier]})
.expectStatus(422);
});
});

View file

@ -108,11 +108,11 @@ describe('Pages Content API', function () {
it('Can include specific tier for page with tiers visibility', async function () {
const res = await agent
.get(`products/`)
.get(`tiers/`)
.expectStatus(200);
const jsonResponse = res.body;
const paidTier = jsonResponse.products.find(p => p.type === 'paid');
const paidTier = jsonResponse.tiers.find(p => p.type === 'paid');
const tiersPage = testUtils.DataGenerator.forKnex.createPost({
slug: 'thou-shalt-be-for-specific-tiers',

View file

@ -280,11 +280,11 @@ describe('Posts Content API', function () {
it('Can include specific tier for post with tiers visibility', async function () {
const res = await agent
.get(`products/`)
.get(`tiers/`)
.expectStatus(200);
const jsonResponse = res.body;
const paidTier = jsonResponse.products.find(p => p.type === 'paid');
const paidTier = jsonResponse.tiers.find(p => p.type === 'paid');
const tiersPost = testUtils.DataGenerator.forKnex.createPost({
slug: 'thou-shalt-be-for-specific-tiers',