mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-11-24 06:35:49 +03:00
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:
parent
33da584161
commit
694721cbea
@ -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);
|
||||
},
|
||||
|
123
core/server/api/canary/tiers.js
Normal file
123
core/server/api/canary/tiers.js
Normal 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;
|
||||
}
|
||||
}
|
||||
};
|
@ -43,6 +43,10 @@ module.exports = {
|
||||
return require('./products');
|
||||
},
|
||||
|
||||
get tiers() {
|
||||
return require('./tiers');
|
||||
},
|
||||
|
||||
get webhooks() {
|
||||
return require('./webhooks');
|
||||
}
|
||||
|
36
core/server/api/canary/utils/serializers/input/tiers.js
Normal file
36
core/server/api/canary/utils/serializers/input/tiers.js
Normal 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];
|
||||
}
|
||||
};
|
@ -73,6 +73,10 @@ module.exports = {
|
||||
return require('./products');
|
||||
},
|
||||
|
||||
get tiers() {
|
||||
return require('./tiers');
|
||||
},
|
||||
|
||||
get member_signin_urls() {
|
||||
return require('./member-signin_urls');
|
||||
},
|
||||
|
210
core/server/api/canary/utils/serializers/output/tiers.js
Normal file
210
core/server/api/canary/utils/serializers/output/tiers.js
Normal 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
|
||||
*/
|
@ -27,6 +27,10 @@ module.exports = {
|
||||
return require('./members');
|
||||
},
|
||||
|
||||
get tiers() {
|
||||
return require('./tiers');
|
||||
},
|
||||
|
||||
get media() {
|
||||
return require('./media');
|
||||
},
|
||||
|
6
core/server/api/canary/utils/validators/input/tiers.js
Normal file
6
core/server/api/canary/utils/validators/input/tiers.js
Normal file
@ -0,0 +1,6 @@
|
||||
const jsonSchema = require('../utils/json-schema');
|
||||
|
||||
module.exports = {
|
||||
add: jsonSchema.validate,
|
||||
edit: jsonSchema.validate
|
||||
};
|
@ -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));
|
||||
|
@ -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",
|
||||
|
95
test/e2e-api/admin/__snapshots__/tiers.test.js.snap
Normal file
95
test/e2e-api/admin/__snapshots__/tiers.test.js.snap
Normal 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",
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
94
test/e2e-api/admin/tiers.test.js
Normal file
94
test/e2e-api/admin/tiers.test.js
Normal 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
|
||||
}]
|
||||
});
|
||||
});
|
||||
});
|
@ -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"
|
||||
|
Loading…
Reference in New Issue
Block a user