mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-03-11 02:12:21 -05:00
Added HTTP BREAD for integrations resource (#9985)
refs #9865 * Added generic messaging for resource not found * Ensured integration model uses transaction for writes * Created POST /integrations endpoint * Created GET /integrations/:id endpoint * Created GET /integrations endpoint * Created PUT /integrations/:id endpoint * Created DELETE /integrations/:id endpoint
This commit is contained in:
parent
da2c292f64
commit
17feb14e4a
10 changed files with 641 additions and 0 deletions
|
@ -6,6 +6,10 @@ module.exports = {
|
|||
return shared.http;
|
||||
},
|
||||
|
||||
get integrations() {
|
||||
return shared.pipeline(require('./integrations'), localUtils);
|
||||
},
|
||||
|
||||
// @TODO: transform
|
||||
get session() {
|
||||
return require('./session');
|
||||
|
|
145
core/server/api/v2/integrations.js
Normal file
145
core/server/api/v2/integrations.js
Normal file
|
@ -0,0 +1,145 @@
|
|||
const common = require('../../lib/common');
|
||||
const models = require('../../models');
|
||||
|
||||
module.exports = {
|
||||
docName: 'integrations',
|
||||
browse: {
|
||||
permissions: true,
|
||||
options: [
|
||||
'include',
|
||||
'limit'
|
||||
],
|
||||
validation: {
|
||||
options: {
|
||||
include: {
|
||||
values: ['api_keys', 'webhooks']
|
||||
}
|
||||
}
|
||||
},
|
||||
query({options}) {
|
||||
return models.Integration.findPage(options);
|
||||
}
|
||||
},
|
||||
read: {
|
||||
permissions: true,
|
||||
data: [
|
||||
'id'
|
||||
],
|
||||
options: [
|
||||
'include'
|
||||
],
|
||||
validation: {
|
||||
data: {
|
||||
id: {
|
||||
required: true
|
||||
}
|
||||
},
|
||||
options: {
|
||||
include: {
|
||||
values: ['api_keys', 'webhooks']
|
||||
}
|
||||
}
|
||||
},
|
||||
query({data, options}) {
|
||||
return models.Integration.findOne(data, Object.assign(options, {require: true}))
|
||||
.catch(models.Integration.NotFoundError, () => {
|
||||
throw new common.errors.NotFoundError({
|
||||
message: common.i18n.t('errors.api.resource.resourceNotFound', {
|
||||
resource: 'Integration'
|
||||
})
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
edit: {
|
||||
permissions: true,
|
||||
data: [
|
||||
'name',
|
||||
'icon_image',
|
||||
'description',
|
||||
'webhooks'
|
||||
],
|
||||
options: [
|
||||
'id',
|
||||
'include'
|
||||
],
|
||||
validation: {
|
||||
options: {
|
||||
id: {
|
||||
required: true
|
||||
},
|
||||
include: {
|
||||
values: ['api_keys', 'webhooks']
|
||||
}
|
||||
}
|
||||
},
|
||||
query({data, options}) {
|
||||
return models.Integration.edit(data, Object.assign(options, {require: true}))
|
||||
.catch(models.Integration.NotFoundError, () => {
|
||||
throw new common.errors.NotFoundError({
|
||||
message: common.i18n.t('errors.api.resource.resourceNotFound', {
|
||||
resource: 'Integration'
|
||||
})
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
add: {
|
||||
statusCode: 201,
|
||||
permissions: true,
|
||||
data: [
|
||||
'name',
|
||||
'icon_image',
|
||||
'description',
|
||||
'webhooks'
|
||||
],
|
||||
options: [
|
||||
'include'
|
||||
],
|
||||
validation: {
|
||||
data: {
|
||||
name: {
|
||||
required: true
|
||||
}
|
||||
},
|
||||
options: {
|
||||
include: {
|
||||
values: ['api_keys', 'webhooks']
|
||||
}
|
||||
}
|
||||
},
|
||||
query({data, options}) {
|
||||
const dataWithApiKeys = Object.assign({
|
||||
api_keys: [
|
||||
{type: 'content'},
|
||||
{type: 'admin'}
|
||||
]
|
||||
}, data);
|
||||
return models.Integration.add(dataWithApiKeys, options);
|
||||
}
|
||||
},
|
||||
destroy: {
|
||||
statusCode: 204,
|
||||
permissions: true,
|
||||
options: [
|
||||
'id'
|
||||
],
|
||||
validation: {
|
||||
options: {
|
||||
id: {
|
||||
required: true
|
||||
}
|
||||
}
|
||||
},
|
||||
query({options}) {
|
||||
return models.Integration.destroy(Object.assign(options, {require: true}))
|
||||
.catch(models.Integration.NotFoundError, () => {
|
||||
throw new common.errors.NotFoundError({
|
||||
message: common.i18n.t('errors.api.resource.resourceNotFound', {
|
||||
resource: 'Integration'
|
||||
})
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
|
@ -1,4 +1,7 @@
|
|||
module.exports = {
|
||||
get integrations() {
|
||||
return require('./integrations');
|
||||
},
|
||||
get pages() {
|
||||
return require('./pages');
|
||||
},
|
||||
|
|
15
core/server/api/v2/utils/serializers/input/integrations.js
Normal file
15
core/server/api/v2/utils/serializers/input/integrations.js
Normal file
|
@ -0,0 +1,15 @@
|
|||
const _ = require('lodash');
|
||||
const debug = require('ghost-ignition').debug('api:v2:utils:serializers:input:integrations');
|
||||
|
||||
module.exports = {
|
||||
add(apiConfig, frame) {
|
||||
debug('add');
|
||||
|
||||
frame.data = _.pick(frame.data.integrations[0], apiConfig.data);
|
||||
},
|
||||
edit(apiConfig, frame) {
|
||||
debug('edit');
|
||||
|
||||
frame.data = _.pick(frame.data.integrations[0], apiConfig.data);
|
||||
}
|
||||
};
|
|
@ -1,4 +1,8 @@
|
|||
module.exports = {
|
||||
get integrations() {
|
||||
return require('./integrations');
|
||||
},
|
||||
|
||||
get pages() {
|
||||
return require('./pages');
|
||||
},
|
||||
|
|
34
core/server/api/v2/utils/serializers/output/integrations.js
Normal file
34
core/server/api/v2/utils/serializers/output/integrations.js
Normal file
|
@ -0,0 +1,34 @@
|
|||
const debug = require('ghost-ignition').debug('api:v2:utils:serializers:output:integrations');
|
||||
|
||||
module.exports = {
|
||||
browse({data, meta}, apiConfig, frame) {
|
||||
debug('browse');
|
||||
|
||||
frame.response = {
|
||||
integrations: data.map(model => model.toJSON(frame.options)),
|
||||
meta
|
||||
};
|
||||
},
|
||||
read(model, apiConfig, frame) {
|
||||
debug('read');
|
||||
|
||||
frame.response = {
|
||||
integrations: [model.toJSON(frame.options)]
|
||||
};
|
||||
},
|
||||
add(model, apiConfig, frame) {
|
||||
debug('add');
|
||||
|
||||
frame.response = {
|
||||
integrations: [model.toJSON(frame.options)]
|
||||
};
|
||||
},
|
||||
edit(model, apiConfig, frame) {
|
||||
debug('edit');
|
||||
|
||||
frame.response = {
|
||||
integrations: [model.toJSON(frame.options)]
|
||||
};
|
||||
}
|
||||
};
|
||||
|
|
@ -10,6 +10,44 @@ const Integration = ghostBookshelf.Model.extend({
|
|||
webhooks: 'webhooks'
|
||||
},
|
||||
|
||||
add(data, options) {
|
||||
const addIntegration = () => {
|
||||
return ghostBookshelf.Model.add.call(this, data, options)
|
||||
.then(({id}) => {
|
||||
return this.findOne({id}, options);
|
||||
});
|
||||
};
|
||||
|
||||
if (!options.transacting) {
|
||||
return ghostBookshelf.transaction((transacting) => {
|
||||
options.transacting = transacting;
|
||||
|
||||
return addIntegration();
|
||||
});
|
||||
}
|
||||
|
||||
return addIntegration();
|
||||
},
|
||||
|
||||
edit(data, options) {
|
||||
const editIntegration = () => {
|
||||
return ghostBookshelf.Model.edit.call(this, data, options)
|
||||
.then(({id}) => {
|
||||
return this.findOne({id}, options);
|
||||
});
|
||||
};
|
||||
|
||||
if (!options.transacting) {
|
||||
return ghostBookshelf.transaction((transacting) => {
|
||||
options.transacting = transacting;
|
||||
|
||||
return editIntegration();
|
||||
});
|
||||
}
|
||||
|
||||
return editIntegration();
|
||||
},
|
||||
|
||||
onSaving(newIntegration, attr, options) {
|
||||
if (this.hasChanged('slug') || !this.get('slug')) {
|
||||
// Pass the new slug through the generator to strip illegal characters, detect duplicates
|
||||
|
|
|
@ -357,6 +357,9 @@
|
|||
"missingFile": "Please select a JSON file.",
|
||||
"invalidFile": "Please select a valid JSON file to import."
|
||||
},
|
||||
"resource": {
|
||||
"resourceNotFound": "{resource} not found."
|
||||
},
|
||||
"routes": {
|
||||
"missingFile": "Please select a YAML file.",
|
||||
"invalidFile": "Please select a valid YAML file to import."
|
||||
|
|
|
@ -33,6 +33,14 @@ module.exports = function apiRoutes() {
|
|||
router.put('/posts/:id', mw.authAdminAPI, apiv2.http(apiv2.posts.edit));
|
||||
router.del('/posts/:id', mw.authAdminAPI, apiv2.http(apiv2.posts.destroy));
|
||||
|
||||
// # Integrations
|
||||
|
||||
router.get('/integrations', mw.authAdminAPI, apiv2.http(apiv2.integrations.browse));
|
||||
router.get('/integrations/:id', mw.authAdminAPI, apiv2.http(apiv2.integrations.read));
|
||||
router.post('/integrations', mw.authAdminAPI, apiv2.http(apiv2.integrations.add));
|
||||
router.put('/integrations/:id', mw.authAdminAPI, apiv2.http(apiv2.integrations.edit));
|
||||
router.del('/integrations/:id', mw.authAdminAPI, apiv2.http(apiv2.integrations.destroy));
|
||||
|
||||
// ## Schedules
|
||||
router.put('/schedules/posts/:id', [
|
||||
auth.authenticate.authenticateClient,
|
||||
|
|
387
core/test/functional/api/v2/admin/integrations_spec.js
Normal file
387
core/test/functional/api/v2/admin/integrations_spec.js
Normal file
|
@ -0,0 +1,387 @@
|
|||
const should = require('should');
|
||||
const supertest = require('supertest');
|
||||
const config = require('../../../../../../core/server/config');
|
||||
const testUtils = require('../../../../utils');
|
||||
const localUtils = require('./utils');
|
||||
|
||||
const ghost = testUtils.startGhost;
|
||||
|
||||
describe('Integrations API', function () {
|
||||
let request;
|
||||
|
||||
before(function () {
|
||||
return ghost()
|
||||
.then(() => {
|
||||
request = supertest.agent(config.get('url'));
|
||||
})
|
||||
.then(() => {
|
||||
return localUtils.doAuth(request, 'integrations');
|
||||
});
|
||||
});
|
||||
|
||||
const findBy = (prop, val) => object => object[prop] === val;
|
||||
|
||||
describe('POST /integrations/', function () {
|
||||
it('Can successfully create a single integration with auto generated content and admin api key', function (done) {
|
||||
request.post(localUtils.API.getApiQuery('integrations/'))
|
||||
.set('Origin', config.get('url'))
|
||||
.send({
|
||||
integrations: [{
|
||||
name: 'Dis-Integrate!!'
|
||||
}]
|
||||
})
|
||||
.expect('Content-Type', /json/)
|
||||
.expect('Cache-Control', testUtils.cacheRules.private)
|
||||
.expect(201)
|
||||
.end(function (err, {body}) {
|
||||
if (err) {
|
||||
return done(err);
|
||||
}
|
||||
|
||||
should.equal(body.integrations.length, 1);
|
||||
|
||||
const [integration] = body.integrations;
|
||||
should.equal(integration.name, 'Dis-Integrate!!');
|
||||
|
||||
should.equal(integration.api_keys.length, 2);
|
||||
|
||||
const contentApiKey = integration.api_keys.find(findBy('type', 'content'));
|
||||
should.equal(contentApiKey.integration_id, integration.id);
|
||||
|
||||
const adminApiKey = integration.api_keys.find(findBy('type', 'admin'));
|
||||
should.equal(adminApiKey.integration_id, integration.id);
|
||||
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('Can successfully create a single integration with a webhook', function (done) {
|
||||
request.post(localUtils.API.getApiQuery('integrations/'))
|
||||
.set('Origin', config.get('url'))
|
||||
.send({
|
||||
integrations: [{
|
||||
name: 'Integratatron4000',
|
||||
webhooks: [{
|
||||
event: 'something',
|
||||
target_url: 'http://example.com',
|
||||
}]
|
||||
}]
|
||||
})
|
||||
.expect('Content-Type', /json/)
|
||||
.expect('Cache-Control', testUtils.cacheRules.private)
|
||||
.expect(201)
|
||||
.end(function (err, {body}) {
|
||||
if (err) {
|
||||
return done(err);
|
||||
}
|
||||
|
||||
should.equal(body.integrations.length, 1);
|
||||
|
||||
const [integration] = body.integrations;
|
||||
should.equal(integration.name, 'Integratatron4000');
|
||||
|
||||
should.equal(integration.webhooks.length, 1);
|
||||
|
||||
const webhook = integration.webhooks[0];
|
||||
should.equal(webhook.integration_id, integration.id);
|
||||
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /integrations/:id', function () {
|
||||
it('Can successfully get a created integration', function (done) {
|
||||
request.post(localUtils.API.getApiQuery('integrations/'))
|
||||
.set('Origin', config.get('url'))
|
||||
.send({
|
||||
integrations: [{
|
||||
name: 'Interrogation Integration'
|
||||
}]
|
||||
})
|
||||
.expect(201)
|
||||
.end(function (err, {body}) {
|
||||
if (err) {
|
||||
return done(err);
|
||||
}
|
||||
const [createdIntegration] = body.integrations;
|
||||
|
||||
request.get(localUtils.API.getApiQuery(`integrations/${createdIntegration.id}/`))
|
||||
.set('Origin', config.get('url'))
|
||||
.expect('Content-Type', /json/)
|
||||
.expect('Cache-Control', testUtils.cacheRules.private)
|
||||
.expect(200)
|
||||
.end(function (err, {body}) {
|
||||
if (err) {
|
||||
return done(err);
|
||||
}
|
||||
|
||||
should.equal(body.integrations.length, 1);
|
||||
|
||||
const [integration] = body.integrations;
|
||||
|
||||
should.equal(integration.id, createdIntegration.id);
|
||||
should.equal(integration.name, createdIntegration.name);
|
||||
should.equal(integration.slug, createdIntegration.slug);
|
||||
should.equal(integration.description, createdIntegration.description);
|
||||
should.equal(integration.icon_image, createdIntegration.icon_image);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('Will 404 if the integration does not exist', function (done) {
|
||||
request.get(localUtils.API.getApiQuery(`integrations/012345678901234567890123/`))
|
||||
.set('Origin', config.get('url'))
|
||||
.expect(404)
|
||||
.end(done);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /integrations/', function () {
|
||||
it('Can successfully get *all* created integrations with api_keys', function (done) {
|
||||
request.post(localUtils.API.getApiQuery('integrations/'))
|
||||
.set('Origin', config.get('url'))
|
||||
.send({
|
||||
integrations: [{
|
||||
name: 'Integrate with this!'
|
||||
}]
|
||||
})
|
||||
.expect(201)
|
||||
.end(function (err) {
|
||||
if (err) {
|
||||
return done(err);
|
||||
}
|
||||
request.post(localUtils.API.getApiQuery('integrations/'))
|
||||
.set('Origin', config.get('url'))
|
||||
.send({
|
||||
integrations: [{
|
||||
name: 'Winter-(is)-great'
|
||||
}]
|
||||
})
|
||||
.expect(201)
|
||||
.end(function (err) {
|
||||
if (err) {
|
||||
return done(err);
|
||||
}
|
||||
|
||||
request.get(localUtils.API.getApiQuery(`integrations/?include=api_keys&limit=all`))
|
||||
.set('Origin', config.get('url'))
|
||||
.expect('Content-Type', /json/)
|
||||
.expect('Cache-Control', testUtils.cacheRules.private)
|
||||
.expect(200)
|
||||
.end(function (err, {body}) {
|
||||
if (err) {
|
||||
return done(err);
|
||||
}
|
||||
|
||||
// This is the only page
|
||||
should.equal(body.meta.pagination.page, 1);
|
||||
should.equal(body.meta.pagination.pages, 1);
|
||||
should.equal(body.meta.pagination.next, null);
|
||||
should.equal(body.meta.pagination.prev, null);
|
||||
|
||||
body.integrations.forEach(integration => {
|
||||
should.exist(integration.api_keys);
|
||||
});
|
||||
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /integrations/:id', function () {
|
||||
it('Can successfully edit a created integration', function (done) {
|
||||
request.post(localUtils.API.getApiQuery('integrations/'))
|
||||
.set('Origin', config.get('url'))
|
||||
.send({
|
||||
integrations: [{
|
||||
name: 'Rubbish Integration Name'
|
||||
}]
|
||||
})
|
||||
.expect(201)
|
||||
.end(function (err, {body}) {
|
||||
if (err) {
|
||||
return done(err);
|
||||
}
|
||||
const [createdIntegration] = body.integrations;
|
||||
request.put(localUtils.API.getApiQuery(`integrations/${createdIntegration.id}/`))
|
||||
.set('Origin', config.get('url'))
|
||||
.send({
|
||||
integrations: [{
|
||||
name: 'Awesome Integration Name',
|
||||
description: 'Finally got round to writing this...'
|
||||
}]
|
||||
})
|
||||
.expect(200)
|
||||
.end(function (err) {
|
||||
if (err) {
|
||||
return done(err);
|
||||
}
|
||||
|
||||
request.get(localUtils.API.getApiQuery(`integrations/${createdIntegration.id}/`))
|
||||
.set('Origin', config.get('url'))
|
||||
.expect('Content-Type', /json/)
|
||||
.expect('Cache-Control', testUtils.cacheRules.private)
|
||||
.expect(200)
|
||||
.end(function (err, {body}) {
|
||||
if (err) {
|
||||
return done(err);
|
||||
}
|
||||
|
||||
const [updatedIntegration] = body.integrations;
|
||||
|
||||
should.equal(updatedIntegration.id, createdIntegration.id);
|
||||
should.equal(updatedIntegration.name, 'Awesome Integration Name');
|
||||
should.equal(updatedIntegration.description, 'Finally got round to writing this...');
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('Can successfully add and delete a created integrations webhooks', function (done) {
|
||||
request.post(localUtils.API.getApiQuery('integrations/'))
|
||||
.set('Origin', config.get('url'))
|
||||
.send({
|
||||
integrations: [{
|
||||
name: 'Webhook-less Integration',
|
||||
}]
|
||||
})
|
||||
.expect(201)
|
||||
.end(function (err, {body}) {
|
||||
if (err) {
|
||||
return done(err);
|
||||
}
|
||||
const [createdIntegration] = body.integrations;
|
||||
request.put(localUtils.API.getApiQuery(`integrations/${createdIntegration.id}/`))
|
||||
.set('Origin', config.get('url'))
|
||||
.send({
|
||||
integrations: [{
|
||||
webhooks: [{
|
||||
event: 'somestuff',
|
||||
target_url: 'http://example.com'
|
||||
}]
|
||||
}]
|
||||
})
|
||||
.expect(200)
|
||||
.end(function (err) {
|
||||
if (err) {
|
||||
return done(err);
|
||||
}
|
||||
|
||||
request.get(localUtils.API.getApiQuery(`integrations/${createdIntegration.id}/?include=webhooks`))
|
||||
.set('Origin', config.get('url'))
|
||||
.expect('Content-Type', /json/)
|
||||
.expect('Cache-Control', testUtils.cacheRules.private)
|
||||
.expect(200)
|
||||
.end(function (err, {body}) {
|
||||
if (err) {
|
||||
return done(err);
|
||||
}
|
||||
|
||||
const [updatedIntegration] = body.integrations;
|
||||
|
||||
should.equal(updatedIntegration.webhooks.length, 1);
|
||||
|
||||
const webhook = updatedIntegration.webhooks[0];
|
||||
should.equal(webhook.integration_id, updatedIntegration.id);
|
||||
|
||||
request.put(localUtils.API.getApiQuery(`integrations/${createdIntegration.id}/`))
|
||||
.set('Origin', config.get('url'))
|
||||
.send({
|
||||
integrations: [{
|
||||
webhooks: []
|
||||
}]
|
||||
})
|
||||
.expect(200)
|
||||
.end(function (err) {
|
||||
if (err) {
|
||||
return done(err);
|
||||
}
|
||||
|
||||
request.get(localUtils.API.getApiQuery(`integrations/${createdIntegration.id}/?include=webhooks`))
|
||||
.set('Origin', config.get('url'))
|
||||
.expect('Content-Type', /json/)
|
||||
.expect('Cache-Control', testUtils.cacheRules.private)
|
||||
.expect(200)
|
||||
.end(function (err, {body}) {
|
||||
if (err) {
|
||||
return done(err);
|
||||
}
|
||||
|
||||
const [updatedIntegration] = body.integrations;
|
||||
|
||||
should.equal(updatedIntegration.webhooks.length, 0);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('Will 404 if the integration does not exist', function (done) {
|
||||
request.put(localUtils.API.getApiQuery(`integrations/012345678901234567890123/`))
|
||||
.set('Origin', config.get('url'))
|
||||
.send({
|
||||
integrations: [{
|
||||
name: 'This better not work'
|
||||
}]
|
||||
})
|
||||
.expect(404)
|
||||
.end(done);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /integrations/:id', function () {
|
||||
it('Can succesfully delete a created integration', function (done) {
|
||||
request.post(localUtils.API.getApiQuery('integrations/'))
|
||||
.set('Origin', config.get('url'))
|
||||
.send({
|
||||
integrations: [{
|
||||
name: 'Short Lived Integration'
|
||||
}]
|
||||
})
|
||||
.expect(201)
|
||||
.end(function (err, {body}) {
|
||||
if (err) {
|
||||
return done(err);
|
||||
}
|
||||
const [createdIntegration] = body.integrations;
|
||||
|
||||
request.del(localUtils.API.getApiQuery(`integrations/${createdIntegration.id}/`))
|
||||
.set('Origin', config.get('url'))
|
||||
.expect(204)
|
||||
.end(function (err) {
|
||||
if (err) {
|
||||
return done(err);
|
||||
}
|
||||
|
||||
request.get(localUtils.API.getApiQuery(`integrations/${createdIntegration.id}/`))
|
||||
.set('Origin', config.get('url'))
|
||||
.expect(404)
|
||||
.end(done);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('Will 404 if the integration does not exist', function (done) {
|
||||
request.del(localUtils.API.getApiQuery(`integrations/012345678901234567890123/`))
|
||||
.set('Origin', config.get('url'))
|
||||
.expect(404)
|
||||
.end(done);
|
||||
});
|
||||
|
||||
it('Will delete the associated api_keys and webhooks', function () {
|
||||
/**
|
||||
* @TODO
|
||||
*
|
||||
* We do not have the /apikeys or /webhooks endpoints yet
|
||||
* This will be manually tested by egg before merging
|
||||
*/
|
||||
});
|
||||
});
|
||||
});
|
Loading…
Add table
Reference in a new issue