diff --git a/src/api/API.ts b/src/api/API.ts index f74799715..abbd78cee 100644 --- a/src/api/API.ts +++ b/src/api/API.ts @@ -10,6 +10,7 @@ import { SendEmailConfiguration } from '../utils/SendEmail' import { AccountController } from '../controllers/AccountController' import { ArchiveController } from '../controllers/ArchiveController' +import { WorkController } from '../controllers/WorkController' import { errorHandling } from '../middlewares/errorHandling' import { logger } from '../middlewares/logger' @@ -41,23 +42,23 @@ interface APIMethods { stop(): Promise } -const init = (accountController: AccountController, archiveController: ArchiveController) => ({ +const init = ( + accountController: AccountController, + archiveController: ArchiveController, + workController: WorkController, +) => ({ maxApiRequestLimitForm, maxApiRequestLimitJson, passwordComplex, sendEmail, - poetUrl, - testPoetUrl, maxApiTokens, loggingConfiguration, }: APIConfiguration) => { const app = new Koa() - const route = routes(accountController, archiveController)( + const route = routes(accountController, archiveController, workController)( passwordComplex, sendEmail, - poetUrl, maxApiTokens, - testPoetUrl, ) app @@ -97,13 +98,14 @@ const stopAPI = async (server: any, logger: Pino.Logger) => { export const API = ( accountController: AccountController, archiveController: ArchiveController, + workController: WorkController, ) => (configuration: APIConfiguration): APIMethods => { const { loggingConfiguration } = configuration const logger = createModuleLogger(loggingConfiguration)(__dirname) return { async start(): Promise { - const app = init(accountController, archiveController)(configuration) + const app = init(accountController, archiveController, workController)(configuration) this.server = await startAPI(app, configuration, logger) return this }, diff --git a/src/api/routes.ts b/src/api/routes.ts index 893bdaad5..a5070816c 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -2,6 +2,7 @@ import * as KoaRouter from 'koa-router' import { AccountController } from '../controllers/AccountController' import { ArchiveController } from '../controllers/ArchiveController' +import { WorkController } from '../controllers/WorkController' import { Authentication } from '../middlewares/authentication' import { Authorization } from '../middlewares/authorization' @@ -41,12 +42,14 @@ import { GetWorks } from './works/GetWorks' import { PostArchive } from './archives/PostArchive' -export const routes = (accountController: AccountController, archiveController: ArchiveController) => ( +export const routes = ( + accountController: AccountController, + archiveController: ArchiveController, + workController: WorkController, +) => ( passwordComplexConfiguration: PasswordComplexConfiguration, sendEmailConfiguration: SendEmailConfiguration, - poetUrl: string, maxApiTokens: number, - testPoetUrl: string, ) => { const router = new KoaRouter() const authentication = Authentication(accountController) @@ -144,10 +147,10 @@ export const routes = (accountController: AccountController, archiveController: router.post( Path.WORKS, validate({ body: CreateWorkSchema, options: { allowUnknown: true } }), - CreateWork(poetUrl, testPoetUrl), + CreateWork(workController), ) - router.get(Path.WORKS_WORKID, validate({ params: GetWorkSchema }), GetWork(poetUrl, testPoetUrl)) - router.get(Path.WORKS, GetWorks(poetUrl, testPoetUrl)) + router.get(Path.WORKS_WORKID, validate({ params: GetWorkSchema }), GetWork(workController)) + router.get(Path.WORKS, GetWorks(workController)) router.post( Path.ARCHIVES, diff --git a/src/api/works/CreateWork.ts b/src/api/works/CreateWork.ts index 4e0a401ca..5a72b3e38 100644 --- a/src/api/works/CreateWork.ts +++ b/src/api/works/CreateWork.ts @@ -1,8 +1,6 @@ import * as Joi from 'joi' -import { errors } from '../../errors/errors' -import { isLiveNetwork } from '../../helpers/token' -import { WorksController } from '../../modules/Works/Works.controller' +import { WorkController } from '../../controllers/WorkController' import { Vault } from '../../utils/Vault/Vault' export const CreateWorkSchema = () => ({ @@ -26,35 +24,19 @@ export const CreateWorkSchema = () => ({ .optional(), }) -export const CreateWork = (poetUrl: string, testPoetUrl: string) => async (ctx: any, next: any): Promise => { - const logger = ctx.logger(__dirname) +export const CreateWork = (workController: WorkController) => async (ctx: any, next: any): Promise => { + const { user, tokenData } = ctx.state - try { - const { user, tokenData } = ctx.state - const { WorkError } = errors + const { '@context': context = {}, ...claim } = ctx.request.body - const { '@context': context = {}, ...newWork } = ctx.request.body + const signedVerifiableClaim = await workController.create( + claim, + context, + user.issuer, + user.privateKey, + tokenData.data.meta.network, + ) - logger.info({ context, newWork }, 'Creating Work') - - const privateKey = await Vault.decrypt(user.privateKey) - const nodeNetwork = isLiveNetwork(tokenData.data.meta.network) ? poetUrl : testPoetUrl - - const work = new WorksController(ctx.logger, nodeNetwork) - const claim = await work.generateClaim(user.issuer, privateKey, newWork, context) - - try { - await work.create(claim) - } catch (e) { - ctx.status = WorkError.code - ctx.body = WorkError.message - return - } - - ctx.status = 200 - ctx.body = { workId: claim.id } - } catch (exception) { - logger.error({ exception }, 'api.CreateWork') - ctx.status = 500 - } + ctx.status = 200 + ctx.body = { workId: signedVerifiableClaim.id } } diff --git a/src/api/works/GetWork.ts b/src/api/works/GetWork.ts index 3864ab0a7..5d98fdea8 100644 --- a/src/api/works/GetWork.ts +++ b/src/api/works/GetWork.ts @@ -1,34 +1,14 @@ import * as Joi from 'joi' -import { errors } from '../../errors/errors' -import { isLiveNetwork } from '../../helpers/token' -import { WorksController } from '../../modules/Works/Works.controller' +import { WorkController } from '../../controllers/WorkController' export const GetWorkSchema = () => ({ workId: Joi.string().required(), }) -export const GetWork = (poetUrl: string, testPoetUrl: string) => async (ctx: any, next: any): Promise => { - const logger = ctx.logger(__dirname) +export const GetWork = (workController: WorkController) => async (ctx: any, next: any): Promise => { + const { workId } = ctx.params + const { tokenData } = ctx.state - try { - const { workId } = ctx.params - const { tokenData } = ctx.state - - const nodeNetwork = isLiveNetwork(tokenData.data.meta.network) ? poetUrl : testPoetUrl - - const worksController = new WorksController(ctx.logger, nodeNetwork) - - try { - const response = await worksController.get(workId) - ctx.body = response.claim - return - } catch (e) { - const { WorkNotFound } = errors - ctx.status = WorkNotFound.code - ctx.body = WorkNotFound.message - } - } catch (exception) { - logger.error({ exception }, 'api.GetWork') - ctx.status = 500 - } + const response = await workController.getById(workId, tokenData.data.meta.network) + ctx.body = response.claim } diff --git a/src/api/works/GetWorks.ts b/src/api/works/GetWorks.ts index ddf45f765..8c05c327f 100644 --- a/src/api/works/GetWorks.ts +++ b/src/api/works/GetWorks.ts @@ -1,31 +1,10 @@ -import { SignedVerifiableClaim } from '@po.et/poet-js' -import { errors } from '../../errors/errors' -import { isLiveNetwork } from '../../helpers/token' -import { WorksController } from '../../modules/Works/Works.controller' +import { WorkController } from '../../controllers/WorkController' -export const GetWorks = (poetUrl: string, testPoetUrl: string) => async (ctx: any, next: any): Promise => { - const logger = ctx.logger(__dirname) +export const GetWorks = (workController: WorkController) => async (ctx: any, next: any): Promise => { + const { user, tokenData } = ctx.state + const { issuer } = user - try { - const { user, tokenData } = ctx.state - const { issuer } = user + const response = await workController.searchWorks({ issuer }, tokenData.data.meta.network) - const nodeNetwork = isLiveNetwork(tokenData.data.meta.network) ? poetUrl : testPoetUrl - - const worksController = new WorksController(ctx.logger, nodeNetwork) - - try { - const response = await worksController.getWorksByIssuer(issuer) - - ctx.body = response.map((work: SignedVerifiableClaim) => work.claim) - return - } catch (e) { - const { WorkNotFound } = errors - ctx.status = WorkNotFound.code - ctx.body = WorkNotFound.message - } - } catch (exception) { - logger.error({ exception }, 'api.GetWorks') - ctx.status = 500 - } + ctx.body = response.map(work => work.claim) } diff --git a/src/app.ts b/src/app.ts index 224c0f072..ae423f702 100644 --- a/src/app.ts +++ b/src/app.ts @@ -5,6 +5,7 @@ import { API } from './api/API' import { Configuration } from './configuration' import { AccountController } from './controllers/AccountController' import { ArchiveController } from './controllers/ArchiveController' +import { WorkController } from './controllers/WorkController' import { AccountDao } from './daos/AccountDao' import { PoetNode } from './daos/PoetNodeDao' import { initVault } from './initVault' @@ -48,6 +49,9 @@ export async function app(localVars: any = {}) { const sendEmail = SendEmail(configurationFrostAPI.sendEmail) + const mainnetNode = PoetNode(configuration.poetUrl) + const testnetNode = PoetNode(configuration.testPoetUrl) + const accountController = AccountController({ dependencies: { logger: logger.child({ file: 'AccountController' }), @@ -61,11 +65,19 @@ export async function app(localVars: any = {}) { }, }) + const workController = WorkController({ + dependencies: { + logger: logger.child({ file: 'WorkController' }), + mainnetNode, + testnetNode, + }, + }) + const archiveController = ArchiveController({ dependencies: { logger: logger.child({ file: 'ArchiveController' }), - mainnetNode: PoetNode(configuration.poetUrl), - testnetNode: PoetNode(configuration.testPoetUrl), + mainnetNode, + testnetNode, }, configuration: { ethereumUrl: configuration.ethereumUrl, @@ -76,7 +88,7 @@ export async function app(localVars: any = {}) { }, }) - const frostAPI = await API(accountController, archiveController)(configurationFrostAPI).start() + const frostAPI = await API(accountController, archiveController, workController)(configurationFrostAPI).start() await accountDao.createIndices() @@ -89,20 +101,6 @@ export async function app(localVars: any = {}) { } } -const configurationToMongoDB = (configuration: Configuration) => ({ - mongodbUrl: configuration.mongodbUrl, - options: { - socketTimeoutMS: configuration.mongodbSocketTimeoutMS, - keepAlive: configuration.mongodbKeepAlive, - reconnectTries: configuration.mongodbReconnectTries, - useNewUrlParser: configuration.mongodbUseNewUrlParser, - }, - loggingConfiguration: { - loggingLevel: configuration.loggingLevel, - loggingPretty: configuration.loggingPretty, - }, -}) - const configurationToFrostAPI = (configuration: Configuration) => ({ host: configuration.frostHost, port: configuration.frostPort, diff --git a/src/controllers/WorkController.ts b/src/controllers/WorkController.ts new file mode 100644 index 000000000..c6d8ababc --- /dev/null +++ b/src/controllers/WorkController.ts @@ -0,0 +1,89 @@ +import { configureCreateVerifiableClaim, getVerifiableClaimSigner, SignedVerifiableClaim } from '@po.et/poet-js' +import * as Pino from 'pino' +import { pipeP } from 'ramda' + +import { PoetNode, WorkSearchFilters } from '../daos/PoetNodeDao' +import { Network } from '../interfaces/Network' +import { Vault } from '../utils/Vault/Vault' + +export interface WorkController { + readonly getById: (id: string, network: Network) => Promise + readonly searchWorks: (filters: WorkSearchFilters, network: Network) => Promise> + readonly create: ( + claim: any, + context: any, + issuer: string, + privateKey: string, + network: Network, + ) => Promise +} + +interface Arguments { + readonly dependencies: Dependencies +} + +interface Dependencies { + readonly logger: Pino.Logger + readonly mainnetNode: PoetNode + readonly testnetNode: PoetNode +} + +export const WorkController = ({ + dependencies: { + logger, + mainnetNode, + testnetNode, + }, +}: Arguments): WorkController => { + const networkToNode = (network: Network) => network === Network.LIVE ? mainnetNode : testnetNode + + const getById = async (id: string, network: Network) => { + const node = networkToNode(network) + return node.getWorkById(id) + } + + const searchWorks = async (filters: WorkSearchFilters, network: Network) => { + const node = networkToNode(network) + return node.searchWorks(filters) + } + + const create = async (claim: any, context: any, issuer: string, encryptedPrivateKey: string, network: Network) => { + const node = networkToNode(network) + + const legacyContext = { + content: 'schema:text', + } + + const aboutContext = { + about: { + '@id': 'schema:url', + '@container': '@list', + }, + } + + const privateKey = await Vault.decrypt(encryptedPrivateKey) + + const createAndSignClaim = pipeP( + configureCreateVerifiableClaim({ issuer, context: { ...legacyContext, ...context, ...aboutContext} }), + getVerifiableClaimSigner().configureSignVerifiableClaim({ privateKey }), + ) + + const { content, ...newWork } = claim + + const [{ archiveUrl = '', hash = '' } = {}] = content ? await node.postArchive(content) : [] + + const signedVerifiableClaim = await createAndSignClaim({ about: [ archiveUrl ], hash, ...newWork }) + + logger.info({ signedVerifiableClaim }) + + await node.postWork(signedVerifiableClaim) + + return signedVerifiableClaim + } + + return { + getById, + searchWorks, + create, + } +} diff --git a/src/daos/PoetNodeDao.ts b/src/daos/PoetNodeDao.ts index eefcf0ae5..cf2870974 100644 --- a/src/daos/PoetNodeDao.ts +++ b/src/daos/PoetNodeDao.ts @@ -1,14 +1,69 @@ +import { SignedVerifiableClaim } from '@po.et/poet-js' import * as FormData from 'form-data' import fetch from 'node-fetch' +import { WorkNotFound } from '../errors/errors' + export interface PoetNode { + readonly getWorkById: (id: string) => Promise + readonly searchWorks: (params: WorkSearchFilters) => Promise> + readonly postWork: (signedVerifiableClaim: SignedVerifiableClaim) => Promise readonly postArchive: (archive: any) => Promise> } +export interface WorkSearchFilters { + readonly issuer: string +} + +const filtersToQueryParams = (filters: any) => + Object.entries(filters).map(([key, value]) => `${key}=${value}`).join('&') + export const PoetNode = (url: string): PoetNode => { - const postArchive = async (archive: any) => { + const getWorkById = async (id: string) => { + const response = await fetch(`${url}/works/${id}`) + + if (!response.ok) { + if (response.status === 404) + throw new WorkNotFound() + throw new Error(await response.text()) + } + + return response.json() + } + + const searchWorks = async (filters: WorkSearchFilters) => { + const response = await fetch(`${url}/works?${filtersToQueryParams(filters)}`) + + if (!response.ok) + throw new Error(await response.text()) + + return response.json() + } + + const postWork = async (signedVerifiableClaim: SignedVerifiableClaim): Promise => { + const response = await fetch(`${url}/works`, { + method: 'post', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(signedVerifiableClaim), + }) + + if (!response.ok) + throw new Error(await response.text()) + } + + const postArchive = async (archive: ReadableStream | string) => { const formData = new FormData() - formData.append('content', archive, { filepath: 'content' }) + formData.append('content', archive, { + filepath: 'content', + ...(typeof archive === 'string' && { + filename: 'content', + knownLength: Buffer.from(archive).length, + contentType: 'plain/text', + }), + }) const response = await fetch(`${url}/files`, { method: 'post', @@ -22,6 +77,9 @@ export const PoetNode = (url: string): PoetNode => { } return { + getWorkById, + searchWorks, + postWork, postArchive, } } diff --git a/src/errors/errors.ts b/src/errors/errors.ts index 5e921be5e..1777bdf35 100644 --- a/src/errors/errors.ts +++ b/src/errors/errors.ts @@ -41,10 +41,6 @@ export const errors = { code: 400, message: 'Could not create the work.', }, - WorkNotFound: { - code: 400, - message: 'Work not found.', - }, InternalErrorExternalAPI: { code: 500, message: @@ -88,6 +84,11 @@ export class ResourceNotFound { message = 'The specified resource does not exist.' } +export class WorkNotFound { + status = 404 + message = 'Work not found.' +} + export class IncorrectOldPassword extends Error { status = 403 message = 'Incorrect Old Password.' diff --git a/src/modules/Works/Works.controller.ts b/src/modules/Works/Works.controller.ts deleted file mode 100644 index 57eb4dbe5..000000000 --- a/src/modules/Works/Works.controller.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { - getVerifiableClaimSigner, - configureCreateVerifiableClaim, -} from '@po.et/poet-js' -import * as FormData from 'form-data' -import fetch from 'node-fetch' -import * as Pino from 'pino' -import { isNil, not, omit, pipe, pipeP } from 'ramda' -import * as str from 'string-to-stream' - -import { Method } from '../../constants' -import { errors } from '../../errors/errors' - -const legacyContext = { - content: 'schema:text', -} - -const aboutContext = { - about: { - '@id': 'schema:url', - '@container': '@list', - }, -} - -export interface WorkAttributes { - readonly [key: string]: string -} - -export class WorksController { - private logger: Pino.Logger - - constructor( - createLogger: (dirname: string) => Pino.Logger, - readonly network: string, - ) { - this.logger = createLogger(__dirname) - } - - async generateClaim(issuer: string, privateKey: string, work: WorkAttributes, context: any) { - const createAndSignClaim = pipeP( - configureCreateVerifiableClaim({ issuer, context: { ...legacyContext, ...context, ...aboutContext} }), - getVerifiableClaimSigner().configureSignVerifiableClaim({ privateKey }), - ) - const { archiveUrl, hash } = (await this.uploadContent(work.content))[0] - const newWork = omit(['content'], work) - return createAndSignClaim({ about: [ archiveUrl ], hash, ...newWork }) - } - - async create(workAttributes: any) { - try { - const createWork = await fetch(this.network + '/works/', { - method: Method.POST, - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - }, - body: JSON.stringify(workAttributes), - }) - - if (createWork.ok) return await createWork.text() - - const errorText = await createWork.text() - const data = { ...createWork, errorText, method: Method.POST } - this.logger.error({ data, workAttributes }, 'WorksController.create') - - throw new Error(errors.InternalErrorExternalAPI.message) - } catch (exception) { - this.logger.error({ exception }, 'WorksController.create') - throw exception - } - } - - private async uploadContent(content: string = '') { - if (isNil(content) || content.length === 0) return [{}] - const formData = new FormData() - - try { - formData.append('content', str(content), { - knownLength: Buffer.from(content).length, - filename: 'content', - contentType: 'plain/text', - }) - - const response = await fetch(`${this.network}/files`, { - method: 'post', - body: formData, - }) - - if (response.ok) return response.json() - - const errorText = await response.text() - const data = { ...response, errorText, method: Method.POST } - this.logger.error({ data, content }, 'WorksController.uploadContent') - - throw new Error('Unable to upload content') - } catch (exception) { - this.logger.error({ exception }, 'WorksController.uploadContent') - throw exception - } - } - - async get(workId: string) { - const work = await fetch(`${this.network}/works/${workId}`) - - if (work.ok) return work.json() - - const errorType = 'Work not found' - const data = { ...work, errorType, method: Method.GET } - this.logger.error({ data, workId }, 'WorksController.get') - - throw new Error(errorType) - } - - async getWorksByIssuer(issuer: string) { - try { - const works = await fetch(`${this.network}/works/?issuer=${issuer}`) - - if (works.ok) return works.json() - - const errorText = await works.text() - const data = { ...works, errorText, method: Method.GET } - this.logger.error({ data, issuer }, 'WorksController.getWorksByIssuer') - - throw new Error('Works not found') - } catch (exception) { - this.logger.error({ exception }, 'WorksController.getWorksByIssuer') - throw exception - } - } -}