UNPKG

reshuffle-eidr-connector

Version:
486 lines (427 loc) 14 kB
import crypto from 'crypto' import xml2js from 'xml2js' import fetch from 'node-fetch' import { BaseConnector, Reshuffle } from 'reshuffle-base-connector' import { validateId } from './validate' import { buildJsonQuery } from './jsonQuery' import { parseJsonWithValue } from './jsonPopulateValue' const availableApiVersions = ['2.6.0', '2.7.0', '2.7.1'] const eidrApiVersion = '2.7.1' // eslint-disable-next-line @typescript-eslint/no-var-requires const eidrConnectorVersion = require('../package.json').version type Obj = Record<string, any> type Options = Record<string, any> enum GraphTraversalTypes { FindAncestors = 'FindAncestors', FindDescendants = 'FindDescendants', GetDependents = 'GetDependents', GetSeriesAncestry = 'GetSeriesAncestry', GetLightweightRelationships = 'GetLightweightRelationships', GetRemotestAncestor = 'GetRemotestAncestor', GetLeafDescendants = 'GetLeafDescendants', GetParent = 'GetParent', GetChildren = 'GetChildren' } interface QueryOptions { idOnly?: boolean pageNumber?: number pageSize?: number root?: string } class EIDRError extends Error { constructor( message: string, public status: number, public details: string = message, ) { super(`EIDRConnector: ${message}`) } } interface CredentialsInterface { userId: string partyId: string password?: string shadow?: string domain?: string } type Credentials = string | CredentialsInterface class Authorization { public readonly endpoint: string public readonly headers: Obj = {} public readonly registered: boolean = false constructor(credentials: Credentials) { function validate(tag: string, value: string): string { if (typeof value !== 'string' || value.trim().length === 0) { throw new EIDRError(`Invalid ${tag}`, 401) } return value.trim() } if (typeof credentials === 'string') { if (!/^Eidr [^\s]+:[^\s]+:[^\s]+$/.test(credentials)) { throw new EIDRError('Invalid credentials string', 401) } const [userId, partyId, shadow] = credentials.substr(5).split(':') this.headers = { Authorization: `Eidr ${userId}:${partyId}:${shadow}` } this.registered = true } else if (credentials.userId) { const userId = validate('userId', credentials.userId) const partyId = validate('partyId', credentials.partyId) let shadow if (credentials.password) { const password = validate('password', credentials.password) shadow = crypto.createHash('md5').update(password).digest('base64') } else if (credentials.shadow) { shadow = validate('shadow', credentials.shadow) } else { throw new EIDRError( 'Missing password', 401, 'Password of shadow must be part of credentials' ) } this.headers = { Authorization: `Eidr ${userId}:${partyId}:${shadow}` } this.registered = true } const domain = typeof credentials === 'string' || !credentials.domain ? 'resolve.eidr.org' : validate('domain', credentials.domain) this.endpoint = `https://${domain}/EIDR/` } } export class EIDRConnector extends BaseConnector { private authorization: Authorization private xmlOptions: Obj constructor(app: Reshuffle, options: Options = { }, id?: string) { super(app, options, id) this.authorization = new Authorization(options as Credentials) this.xmlOptions = { trim: true, explicitArray: false, } } private renderOperationRequest(operation: string) { return ` <Request xmlns="http://www.eidr.org/schema"> <Operation> ${operation} </Operation> </Request> ` } private renderQueryRequest(query: string, opts: QueryOptions) { return this.renderOperationRequest(` <Query> ${opts.root ? `<ID>${opts.root}</ID>` : ''} <Expression><![CDATA[${query}]]></Expression> <PageNumber>${opts.pageNumber || 1}</PageNumber> <PageSize>${opts.pageSize || 25}</PageSize> </Query> `) } private renderGraphTraversalRequest(id: string, graphRequest: GraphTraversalTypes) { return this.renderOperationRequest(` <${graphRequest}> <ID>${id}</ID> </${graphRequest}> `) } // May be needed in the future // private renderRelationshipsRequest(id: string) { // return this.renderOperationRequest(` // <GetLightweightRelationships> // <ID>${id}</ID> // </GetLightweightRelationships> // `) // } private async request( method: 'GET' | 'POST', path: string, auth: Authorization = this.authorization, body?: string, customEidrVersion?: string ) { const res = await fetch(auth.endpoint + path, { method, headers: { ...auth.headers, 'Content-Type': 'text/xml', 'EIDR-Version': customEidrVersion || eidrApiVersion, }, ...(body ? { body } : {}), }) if (res.status !== 200) { throw new EIDRError( 'API error', res.status, `HTTP error accessing EIDR registry API: ${res.status} ${res.statusText}`, ) } const xml = await res.text() return xml2js.parseStringPromise(xml, this.xmlOptions) } // Actions //////////////////////////////////////////////////////// public info() { return { eidrApiVersion, eidrConnectorVersion, availableApiVersions } } public async graphTraversal( id: string, graphRequest: GraphTraversalTypes, credentials?: Credentials, apiVersion?: string ) { const auth: Authorization = credentials ? new Authorization(credentials) : this.authorization if (!auth.registered) { throw new EIDRError( 'Unregistered', 401, 'Query requires registered user credentials' ) } if (!graphRequest || Object.values(GraphTraversalTypes) .indexOf(graphRequest) === -1) { throw new EIDRError( 'Invalid graph traversal request', 500, `A valid graph request type must be provided: FindAncestors, FindDescendants, GetDependents, GetSeriesAncestry, GetLightweightRelationships, GetRemotestAncestor, GetLeafDescendants, GetParent, GetChildren`, ) } if (!id) { throw new EIDRError( 'Invalid graph traversal request', 500, 'EIDR ID must be provided', ) } const req = this.renderGraphTraversalRequest(id, graphRequest); const obj = await this.request( 'POST', 'object/graph', auth, req, apiVersion ) const res = obj.Response if (res.Status.Code !== '0') { throw new EIDRError( `Error ${res.Status.Code} ${res.Status.Type}`, (res.Status.Code === '4' || res.Status.Code === '5') ? 403 : 500, res.Status.Details, ) } return res.SimpleMetadata ? parseJsonWithValue(res.SimpleMetadata) : null } public async query( exprOrObj: string | Obj, options: QueryOptions = {}, credentials?: Credentials, apiVersion?: string, ) { const auth: Authorization = credentials ? new Authorization(credentials) : this.authorization if (!auth.registered) { throw new EIDRError( 'Unregistered', 401, 'Query requires registered user credentials' ) } const expr = typeof exprOrObj === 'string' ? exprOrObj : typeof exprOrObj === 'object' ? buildJsonQuery(exprOrObj) : undefined if (expr === undefined) { throw new EIDRError( 'Invalid query', 500, `Query must be a string or an object: ${typeof exprOrObj}`, ) } const req = this.renderQueryRequest(expr, options) const obj = await this.request( 'POST', `query/${options.idOnly ? '?type=ID' : ''}`, auth, req, apiVersion ) const res = obj.Response if (res.Status.Code !== '0') { throw new EIDRError( `Error ${res.Status.Code} ${res.Status.Type}`, (res.Status.Code === '4' || res.Status.Code === '5') ? 403 : 500, res.Status.Details, ) } if (res.QueryResults) { const data = res.QueryResults[options.idOnly ? 'ID' : 'SimpleMetadata'] const array = data ? (Array.isArray(data) ? data : [data]) : [] return { totalMatches: Number(res.QueryResults.TotalMatches), results: parseJsonWithValue(array), } } throw new EIDRError( 'Unrecognized response', 500, 'Unrecognized response from registry', ) } public async resolve(id: string, type = 'Full', credentials?: Credentials, apiVersion?: string) { if (!validateId(id)) { throw new EIDRError('Invalid ID', 400, `Invalid EIDR ID: ${id}`) } if (id.startsWith('10.5240')) { return this.resolveContentID(id, type, credentials, apiVersion) } if (id.startsWith('10.5239') || id.startsWith('10.5237')) { return this.resolveOtherID(id, type, credentials, apiVersion) } throw new EIDRError( 'Unsupported type', 500, `Unsupported record type: ${id.substring(0, 7)}`, ) } private async resolveContentID( id: string, type = 'Full', credentials?: Credentials, apiVersion?: string) { const auth: Authorization | undefined = credentials ? new Authorization(credentials) : undefined if ( type !== 'AlternateIDs' && type !== 'DOIKernel' && type !== 'Full' && type !== 'LinkedAlternateIDs' && type !== 'Provenance' && type !== 'SelfDefined' && type !== 'Simple' ) { throw new EIDRError( 'Unsupported type', 500, `Unsupported resolution type: id=${id} type=${type}`, ) } const pth = `object/${encodeURIComponent(id)}?type=${type}` const res = await this.request('GET', pth, auth, undefined, apiVersion) if (res.Response && res.Response.Status && res.Response.Status.Code !== '0') { throw new EIDRError( `Error ${res.Response.Status.Code} ${res.Response.Status.Type}`, 500, `Registry error: id=${id} type=${res.Response.Status.Type}`, ) } if (type === 'Full' || type === 'SelfDefined') { const attr = `${type}Metadata` if (!res[attr] || !res[attr].BaseObjectData) { throw new EIDRError( 'Unrecognized response', 500, `Unrecognized response resolving: id=${id} type=${type}`, ) } return parseJsonWithValue({ ...res[attr].BaseObjectData, ExtraObjectMetadata: res[attr].ExtraObjectMetadata, }) } if (type === 'AlternateIDs' || type === 'LinkedAlternateIDs') { const prop = type.slice(0, -1) if (!res[type]) { throw new EIDRError( 'Unrecognized response', 500, `Unrecognized response resolving: id=${id} type=${type}`, ) } return parseJsonWithValue({ ID: res[type].ID, [prop]: res[type][prop] || [], }) } // type === 'Simple'|| type === 'Provenance' || type === 'DOIKernel' const attr = `${type === 'DOIKernel' ? 'kernel' : type}Metadata` if (!res[attr]) { throw new EIDRError( 'Unrecognized response', 500, `Unrecognized response resolving: id=${id} type=${type}`, ) } // eslint-disable-next-line @typescript-eslint/no-unused-vars, no-shadow const { $, ...response } = res[attr] return parseJsonWithValue(response) } private async resolveOtherID( id: string, type = 'Full', credentials?: Credentials, apiVersion?: string) { if (type !== 'Full' && type !== 'DOIKernel') { throw new EIDRError( 'Unsupported type', 500, `Unsupported resolution: id=${id} type=${type}`, ) } const auth: Authorization | undefined = credentials ? new Authorization(credentials) : undefined const prefix = id.startsWith('10.5237') ? 'party' : 'service' const pth = `${prefix}/resolve/${encodeURIComponent(id)}?type=${type}` const res = await this.request('GET', pth, auth, undefined, apiVersion) if (res.Response && res.Response.Status && res.Response.Status.Code !== '0') { throw new EIDRError( `Error ${res.Response.Status.Code} ${res.Response.Status.Type}`, 500, `Registry error: id=${id} type=${res.Response.Status.Type}`, ) } const which = id.startsWith('10.5237') ? 'Party' : 'Service' const payload = res && res[type === 'Full' ? which : 'kernelMetadata'] if (!payload) { throw new EIDRError( 'Unrecognized response', 500, `Unrecognized response resolving: id=${id} type=${type}`, ) } // eslint-disable-next-line @typescript-eslint/no-unused-vars, no-shadow const { $, ...response } = payload return parseJsonWithValue(response) } public async simpleQuery( exprOrObj: string | Obj, compareFunction?: (a: Obj, b: Obj) => number, credentials?: Credentials, ) { const { results } = await this.query(exprOrObj, {}, credentials) const defaultCompareFunction = ((a: any, b: any) => ( (new Date(b.ReleaseDate)).getTime() - (new Date(a.ReleaseDate)).getTime() )) return results.sort(compareFunction || defaultCompareFunction) } }