Skip to content

Commit

Permalink
move pubrouter function to api codebase, add tests
Browse files Browse the repository at this point in the history
  • Loading branch information
finlay-jisc committed Jan 23, 2025
1 parent 2706920 commit 784107b
Show file tree
Hide file tree
Showing 17 changed files with 343 additions and 348 deletions.
1 change: 1 addition & 0 deletions .github/workflows/api-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ jobs:
DATACITE_PASSWORD: ${{ secrets.DATACITE_PASSWORD }}
ORCID_ID: ${{ secrets.ORCID_ID }}
ORCID_SECRET: ${{ secrets.ORCID_SECRET }}
PUBROUTER_API_KEYS: ${{ secrets.PUBROUTER_API_KEYS }}
run: |
export SERVERLESS_LICENSE_KEY=`aws ssm get-parameter --name ${{ secrets.SERVERLESS_LICENSE_KEY_SSM_PARAMETER_ARN }} --query "Parameter.Value" --output text`
docker compose up -d --build
Expand Down
2 changes: 2 additions & 0 deletions api/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,8 @@ services:
- TRIGGER_ARI_INGEST_API_KEY=123456789
- [email protected]
- PARTICIPATING_ARI_USER_IDS=
- PUBROUTER_API_KEYS
- [email protected]

volumes:
opensearch-data1:
24 changes: 24 additions & 0 deletions api/prisma/seeds/local/unitTesting/publications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1242,6 +1242,30 @@ const publicationSeeds: Prisma.PublicationCreateInput[] = [
}
}
}
},
{
id: 'seed-publication',
doi: '10.82259/cty5-2g34',
type: 'PROBLEM',
versions: {
create: {
id: 'seed-publication-v1',
doi: '10.82259/ver1-2g34',
versionNumber: 1,
title: 'Seed publication',
content: 'Seed publication content',
currentStatus: 'LIVE',
isLatestLiveVersion: true,
publishedDate: '2025-01-23T10:42:00.000Z',
user: { connect: { id: 'octopus' } },
publicationStatus: {
create: [
{ status: 'DRAFT', createdAt: '2025-01-23T09:42:00.000Z' },
{ status: 'LIVE', createdAt: '2025-01-23T10:42:00.000Z' }
]
}
}
}
}
];

Expand Down
10 changes: 9 additions & 1 deletion api/serverless-config-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,12 @@ functions:
generatePDFsFromQueue:
handler: dist/src/components/sqs/handler.generatePDFs
events:
- sqs: 'arn:aws:sqs:${aws:region}:${aws:accountId}:science-octopus-pdf-queue-${self:provider.stage}'
- sqs: 'arn:aws:sqs:${aws:region}:${aws:accountId}:science-octopus-pdf-queue-${self:provider.stage}'
notifyPubRouter:
handler: dist/src/components/pubRouter/controller.notifyPubRouter
events:
- s3:
bucket: 'science-octopus-publishing-pdfs-${self:provider.stage}'
event: s3:ObjectCreated:*
existing: true
forceDeploy: true
9 changes: 9 additions & 0 deletions api/serverless-config-local.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Config only used locally
functions:
notifyPubRouter:
handler: dist/src/components/pubRouter/controller.notifyPubRouter
events:
- http:
path: ${self:custom.versions.v1}/publications/{publicationId}/notifyPubRouter
method: POST
cors: true
3 changes: 3 additions & 0 deletions api/serverless-offline.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ provider:
TRIGGER_ARI_INGEST_API_KEY: ${env:TRIGGER_ARI_INGEST_API_KEY}
INGEST_REPORT_RECIPIENTS: ${env:INGEST_REPORT_RECIPIENTS}
PARTICIPATING_ARI_USER_IDS: ${env:PARTICIPATING_ARI_USER_IDS}
PUBROUTER_API_KEYS: ${env:PUBROUTER_API_KEYS}
PUBROUTER_FAILURE_CHANNEL: ${env:PUBROUTER_FAILURE_CHANNEL}
deploymentBucket:
tags:
Project: Octopus
Expand Down Expand Up @@ -72,5 +74,6 @@ custom:
useChildProcesses: true
functions:
- ${file(./serverless-config-default.yml):functions}
- ${file(./serverless-config-local.yml):functions}
package:
patterns: ${file(./serverless-config-default.yml):package.defaultPatterns}
2 changes: 2 additions & 0 deletions api/serverless.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ provider:
ECS_TASK_DEFINITION_ID: ${ssm:/ecs_task_definition_id_${self:provider.stage}_octopus}
ECS_TASK_SECURITY_GROUP_ID: ${ssm:/ecs_task_security_group_id_${self:provider.stage}_octopus}
PRIVATE_SUBNET_IDS: ${ssm:/${self:provider.stage}_octopus_private_subnet_az1},${ssm:/${self:provider.stage}_octopus_private_subnet_az2},${ssm:/${self:provider.stage}_octopus_private_subnet_az3}
PUBROUTER_API_KEYS: ${ssm:/pubrouter_api_keys_${self:provider.stage}_octopus}
PUBROUTER_FAILURE_CHANNEL: ${ssm:/pubrouter_failure_channel_${self:provider.stage}_octopus}
deploymentBucket:
tags:
Project: Octopus
Expand Down
29 changes: 29 additions & 0 deletions api/src/components/pubRouter/__tests__/notifyPubRouter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import * as testUtils from 'lib/testUtils';

beforeAll(async () => {
await testUtils.clearDB();
await testUtils.testSeed();
});

describe('Notify pubRouter of a publication', () => {
test('Notify of existing non-seed publication', async () => {
const response = await testUtils.agent.post('/publications/publication-problem-live/notifyPubRouter');

expect(response.status).toEqual(200);
expect(response.body.message).toEqual('Successfully submitted to publications router');
});

test('Notify of seed publication', async () => {
const response = await testUtils.agent.post('/publications/seed-publication/notifyPubRouter');

expect(response.status).toEqual(200);
expect(response.body.message).toEqual('Publication author is Octopus user, ignoring');
});

test('Notify of non-existent publication', async () => {
const response = await testUtils.agent.post('/publications/made-up-publication/notifyPubRouter');

expect(response.status).toEqual(404);
expect(response.body.message).toEqual('Publication version not found.');
});
});
60 changes: 60 additions & 0 deletions api/src/components/pubRouter/controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { S3CreateEvent } from 'aws-lambda';

import * as Helpers from 'lib/helpers';
import * as I from 'interface';
import * as pubRouterService from './service';
import * as publicationVersionService from 'publicationVersion/service';
import * as response from 'lib/response';
import * as s3 from 'lib/s3';

/**
* Fires a notification to the PubRouter API with details of a new publication when it has a PDF generated.
* @param event When a file is deposited into the PDF S3 bucket (on deployed environments), or when
* a request is made to the API to trigger this function (locally).
* @returns A response object with a status code and message.
*/
export const notifyPubRouter = async (
event: S3CreateEvent | I.APIRequest<undefined, undefined, I.LocalNotifyPubRouterPathParams>
): Promise<I.JSONResponse> => {
let publicationId: string;
let pdfUrl: string;

// If this is running locally, get details from lambda event.
if (process.env.STAGE === 'local' && Object.hasOwn(event, 'pathParameters')) {
const lambdaEvent = event as I.APIRequest<undefined, undefined, I.LocalNotifyPubRouterPathParams>;
publicationId = lambdaEvent.pathParameters.publicationId;
pdfUrl = Helpers.checkEnvVariable('LOCALSTACK_SERVER') + s3.buckets.pdfs + `/${publicationId}.pdf`;
} else {
// Otherwise, the "real" way, get details from S3 event.
const s3Event = event as S3CreateEvent;
const bucket = s3Event.Records[0].s3.bucket.name;
const key = s3Event.Records[0].s3.object.key;
pdfUrl = `https://${bucket}.s3.amazonaws.com/${key}`;
publicationId = key.replace(/\.pdf$/, '');
}

try {
const publicationVersion = await publicationVersionService.get(publicationId, 'latest');

if (!publicationVersion) {
return response.json(404, {
message: 'Publication version not found.'
});
}

// If publication was written by Science Octopus (seed data), don't send.
if (publicationVersion.user && publicationVersion.user.id === 'octopus') {
console.log('Publication author is Octopus user, ignoring');

return response.json(200, { message: 'Publication author is Octopus user, ignoring' });
}

const { code, message } = await pubRouterService.notifyPubRouter(publicationVersion, pdfUrl);

return response.json(code, { message });
} catch (err) {
console.log(err);

return response.json(500, { message: 'Unknown server error.' });
}
};
187 changes: 187 additions & 0 deletions api/src/components/pubRouter/service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import * as email from 'lib/email';
import * as Helpers from 'lib/helpers';
import * as I from 'interface';

const pubRouterPublicationTypePrefix = 'Octopus article; ';

const getPubRouterMetadata = (publicationVersion: I.PublicationVersion, pdfUrl: string) => {
const formatCoAuthor = (coAuthor: I.PublicationVersion['coAuthors'][0]) => {
if (!coAuthor.user) return null;

return {
type: coAuthor.linkedUser === publicationVersion.user.id ? 'corresp' : 'author',
name: {
firstname: coAuthor.user.firstName,
surname: coAuthor.user.lastName || '',
fullname: `${coAuthor.user.lastName && coAuthor.user.lastName + ', '}${coAuthor.user.firstName}`
},
identifier: [
{
type: 'orcid',
id: coAuthor.user.orcid
}
],
affiliations: coAuthor.affiliations?.map((rawAffiliationJson) => {
const affiliation: I.MappedOrcidAffiliation = JSON.parse(rawAffiliationJson as string);

return {
identifier: [
...(affiliation.organization['disambiguated-organization'] &&
affiliation.organization['disambiguated-organization']['disambiguation-source'] === 'ROR'
? [
{
type: 'ROR',
id: affiliation.organization['disambiguated-organization'][
'disambiguated-organization-identifier'
]
}
]
: []),
...(affiliation.url
? [
{
type: 'Link',
id: affiliation.url
}
]
: [])
],
org: affiliation.organization.name,
city: affiliation.organization.address.city,
country: affiliation.organization.address.country
};
})
};
};

const formattedPublicationDate = publicationVersion.createdAt.toISOString().split('T')[0];
const publication = publicationVersion.publication;
const formattedCoAuthors = publicationVersion.coAuthors?.map((coAuthor) => formatCoAuthor(coAuthor));

return {
provider: {
agent: 'Octopus'
},
links: [
{
format: 'application/pdf',
url: pdfUrl
}
],
metadata: {
journal: {
title: 'Octopus',
publisher: ['Octopus'],
identifier: [
{
type: 'doi',
id: 'https://doi.org/10.57874/OCTOPUS'
}
]
},
article: {
title: publicationVersion.title,
abstract: publicationVersion.description,
type: `${pubRouterPublicationTypePrefix}${publicationVersion.publication.type}`,
version: 'SMUR',
language: [publicationVersion.language],
identifier: [
{
type: 'doi',
id: publication.doi
}
],
e_num: publication.id // DOI suffix e.g. "abcd-efgh"
},
author: formattedCoAuthors,
publication_date: {
date: formattedPublicationDate,
year: formattedPublicationDate.slice(0, 4),
month: formattedPublicationDate.slice(5, 7),
day: formattedPublicationDate.slice(8, 10)
},
accepted_date: formattedPublicationDate,
publication_status: 'Published',
funding: publicationVersion.funders?.map((funder) => ({
name: funder.name,
...(funder.ror && {
identifier: [
{
type: 'ror',
id: funder.ror
}
]
}),
...(funder.grantId && { grant_numbers: [funder.grantId] })
})),
license_ref: [
{
title: 'CC-BY 4.0',
type: 'CC-BY 4.0',
url: 'https://creativecommons.org/licenses/by/4.0/'
}
],
peer_reviewed: false
}
};
};

const postToPubRouter = async (pdfMetadata: ReturnType<typeof getPubRouterMetadata>, endpoint: string) =>
fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(pdfMetadata)
});

export const notifyPubRouter = async (
publicationVersion: I.PublicationVersion,
pdfUrl: string
): Promise<{ code: number; message: string }> => {
const pdfMetadata = getPubRouterMetadata(publicationVersion, pdfUrl);
console.log('PDF metadata: ', JSON.stringify(pdfMetadata));

// Send to PubRouter.
const publicationType = pdfMetadata.metadata.article.type.replace(pubRouterPublicationTypePrefix, '');
// We use a different API key per publication type.
const apiKeys = JSON.parse(Helpers.checkEnvVariable('PUBROUTER_API_KEYS'));

if (!Object.keys(apiKeys).includes(publicationType)) {
throw new Error(`Publication type "${publicationType}" not found in API keys object`);
} else {
const apiKey = apiKeys[publicationType];
// Hit UAT endpoint if not running on prod.
const apiEndpoint = `https://${
process.env.STAGE !== 'prod' ? 'uat.' : ''
}pubrouter.jisc.ac.uk/api/v4/notification?api_key=${apiKey}`;

try {
const response = await postToPubRouter(pdfMetadata, apiEndpoint);
// Check the API response and handle failures
const successResponse = { code: 200, message: 'Successfully submitted to publications router' };

if (response.ok) {
return successResponse;
} else {
// Retry once
console.log('First attempt failed; retrying');
const retry = await postToPubRouter(pdfMetadata, apiEndpoint);

if (retry.ok) {
return successResponse;
} else {
const retryJSON = await retry.json();
await email.pubRouterFailure(publicationVersion.versionOf, JSON.stringify(retryJSON));

return { code: 500, message: 'Failed to submit to publications router' };
}
}
} catch (error) {
const errorString = error.toString();
await email.pubRouterFailure(publicationVersion.versionOf, errorString);

return { code: 500, message: errorString };
}
}
};
Loading

0 comments on commit 784107b

Please sign in to comment.