reshuffle-eidr-connector
Version:
Reshuffle connectors for eidr.org
486 lines (427 loc) • 14 kB
text/typescript
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)
}
}