- fix: Update API routes to include DOI URL handling and improve route organization - chore: Add ORCID preload rule file and ensure proper registration - docs: Add MIT License to the project for open-source compliance - feat: Implement command to detect and fix missing dataset cross-references - feat: Create command for updating DataCite DOI records with detailed logging and error handling - docs: Add comprehensive documentation for dataset indexing command - docs: Create detailed documentation for DataCite update command with usage examples and error handling
326 lines
13 KiB
TypeScript
326 lines
13 KiB
TypeScript
import DoiClientContract from '#app/Library/Doi/DoiClientContract';
|
|
import DoiClientException from '#app/exceptions/DoiClientException';
|
|
import { StatusCodes } from 'http-status-codes';
|
|
import logger from '@adonisjs/core/services/logger';
|
|
import { AxiosResponse } from 'axios';
|
|
import { default as axios } from 'axios';
|
|
|
|
export class DoiClient implements DoiClientContract {
|
|
public username: string;
|
|
public password: string;
|
|
public serviceUrl: string;
|
|
public apiUrl: string;
|
|
|
|
constructor() {
|
|
// const datacite_environment = process.env.DATACITE_ENVIRONMENT || 'debug';
|
|
this.username = process.env.DATACITE_USERNAME || '';
|
|
this.password = process.env.DATACITE_PASSWORD || '';
|
|
this.serviceUrl = process.env.DATACITE_SERVICE_URL || '';
|
|
this.apiUrl = process.env.DATACITE_API_URL || 'https://api.datacite.org';
|
|
|
|
if (this.username === '' || this.password === '' || this.serviceUrl === '') {
|
|
const message = 'issing configuration settings to properly initialize DOI client';
|
|
logger.error(message);
|
|
throw new DoiClientException(StatusCodes.BAD_REQUEST, message);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Creates a DOI with the given identifier
|
|
*
|
|
* @param doiValue The desired DOI identifier e.g. '10.5072/tethys.999',
|
|
* @param xmlMeta
|
|
* @param landingPageUrl e.g. https://www.tethys.at/dataset/1
|
|
*
|
|
* @return Promise<AxiosResponse<any>> The http response in the form of a axios response
|
|
*/
|
|
public async registerDoi(doiValue: string, xmlMeta: string, landingPageUrl: string): Promise<AxiosResponse<any>> {
|
|
//step 1: register metadata via xml upload
|
|
// state draft
|
|
// let response;
|
|
// let url = `${this.serviceUrl}/metadata/${doiValue}`; //https://mds.test.datacite.org/metadata/10.21388/tethys.213
|
|
const auth = {
|
|
username: this.username,
|
|
password: this.password,
|
|
};
|
|
let headers = {
|
|
'Content-Type': 'application/xml;charset=UTF-8',
|
|
};
|
|
try {
|
|
const metadataResponse = await axios.put(`${this.serviceUrl}/metadata/${doiValue}`, xmlMeta, { auth, headers });
|
|
|
|
// Response Codes
|
|
// 201 Created: operation successful
|
|
// 401 Unauthorised: no login
|
|
// 403 Forbidden: login problem, quota exceeded
|
|
// 415 Wrong Content Type : Not including content type in the header.
|
|
// 422 Unprocessable Entity : invalid XML
|
|
// let test = metadataResponse.data; // 'OK (10.21388/TETHYS.213)'
|
|
if (metadataResponse.status !== 201) {
|
|
const message = `Unexpected DataCite MDS response code ${metadataResponse.status}`;
|
|
logger.error(message);
|
|
throw new DoiClientException(metadataResponse.status, message);
|
|
}
|
|
|
|
const doiResponse = await axios.put(`${this.serviceUrl}/doi/${doiValue}`, `doi=${doiValue}\nurl=${landingPageUrl}`, {
|
|
auth,
|
|
headers,
|
|
});
|
|
|
|
// Response Codes
|
|
// 201 Created: operation successful
|
|
// 400 Bad Request: request body must be exactly two lines: DOI and URL; wrong domain, wrong prefix;
|
|
// 401 Unauthorised: no login
|
|
// 403 Forbidden: login problem, quota exceeded
|
|
// 412 Precondition failed: metadata must be uploaded first.
|
|
if (doiResponse.status !== 201) {
|
|
const message = `Unexpected DataCite MDS response code ${doiResponse.status}`;
|
|
logger.error(message);
|
|
throw new DoiClientException(doiResponse.status, message);
|
|
}
|
|
|
|
return doiResponse;
|
|
} catch (error) {
|
|
// const message = `request for registering DOI failed with ${error.message}`;
|
|
// Handle the error, log it, or rethrow as needed
|
|
logger.error(error.message);
|
|
throw new DoiClientException(error.response.status, error.response.data);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Retrieves DOI information from DataCite REST API
|
|
*
|
|
* @param doiValue The DOI identifier e.g. '10.5072/tethys.999'
|
|
* @returns Promise with DOI information or null if not found
|
|
*/
|
|
public async getDoiInfo(doiValue: string): Promise<any | null> {
|
|
try {
|
|
// Use configurable DataCite REST API URL
|
|
const dataciteApiUrl = `${this.apiUrl}/dois/${doiValue}`;
|
|
const response = await axios.get(dataciteApiUrl, {
|
|
headers: {
|
|
Accept: 'application/vnd.api+json',
|
|
},
|
|
});
|
|
|
|
if (response.status === 200 && response.data.data) {
|
|
return {
|
|
created: response.data.data.attributes.created,
|
|
registered: response.data.data.attributes.registered,
|
|
updated: response.data.data.attributes.updated,
|
|
published: response.data.data.attributes.published,
|
|
state: response.data.data.attributes.state,
|
|
url: response.data.data.attributes.url,
|
|
metadata: response.data.data.attributes,
|
|
};
|
|
}
|
|
} catch (error) {
|
|
if (error.response?.status === 404) {
|
|
logger.debug(`DOI ${doiValue} not found in DataCite`);
|
|
return null;
|
|
}
|
|
|
|
logger.debug(`DataCite REST API failed for ${doiValue}: ${error.message}`);
|
|
|
|
// Fallback to MDS API
|
|
return await this.getDoiInfoFromMds(doiValue);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Fallback method to get DOI info from MDS API
|
|
*
|
|
* @param doiValue The DOI identifier
|
|
* @returns Promise with basic DOI information or null
|
|
*/
|
|
private async getDoiInfoFromMds(doiValue: string): Promise<any | null> {
|
|
try {
|
|
const auth = {
|
|
username: this.username,
|
|
password: this.password,
|
|
};
|
|
|
|
// Get DOI URL
|
|
const doiResponse = await axios.get(`${this.serviceUrl}/doi/${doiValue}`, { auth });
|
|
|
|
if (doiResponse.status === 200) {
|
|
// Get metadata if available
|
|
try {
|
|
const metadataResponse = await axios.get(`${this.serviceUrl}/metadata/${doiValue}`, {
|
|
auth,
|
|
headers: {
|
|
Accept: 'application/xml',
|
|
},
|
|
});
|
|
|
|
return {
|
|
url: doiResponse.data.trim(),
|
|
metadata: metadataResponse.data,
|
|
created: new Date().toISOString(), // MDS doesn't provide creation dates
|
|
registered: new Date().toISOString(), // Use current time as fallback
|
|
source: 'mds',
|
|
};
|
|
} catch (metadataError) {
|
|
// Return basic info even if metadata fetch fails
|
|
return {
|
|
url: doiResponse.data.trim(),
|
|
created: new Date().toISOString(),
|
|
registered: new Date().toISOString(),
|
|
source: 'mds',
|
|
};
|
|
}
|
|
}
|
|
} catch (error) {
|
|
if (error.response?.status === 404) {
|
|
logger.debug(`DOI ${doiValue} not found in DataCite MDS`);
|
|
return null;
|
|
}
|
|
|
|
logger.debug(`DataCite MDS API failed for ${doiValue}: ${error.message}`);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Checks if a DOI exists in DataCite
|
|
*
|
|
* @param doiValue The DOI identifier
|
|
* @returns Promise<boolean> True if DOI exists
|
|
*/
|
|
public async doiExists(doiValue: string): Promise<boolean> {
|
|
const doiInfo = await this.getDoiInfo(doiValue);
|
|
return doiInfo !== null;
|
|
}
|
|
|
|
/**
|
|
* Gets the last modification date of a DOI
|
|
*
|
|
* @param doiValue The DOI identifier
|
|
* @returns Promise<Date | null> Last modification date or creation date if never updated, null if not found
|
|
*/
|
|
public async getDoiLastModified(doiValue: string): Promise<Date | null> {
|
|
const doiInfo = await this.getDoiInfo(doiValue);
|
|
|
|
if (doiInfo) {
|
|
// Use updated date if available, otherwise fall back to created/registered date
|
|
const dateToUse = doiInfo.updated || doiInfo.registered || doiInfo.created;
|
|
|
|
if (dateToUse) {
|
|
logger.debug(
|
|
`DOI ${doiValue}: Using ${doiInfo.updated ? 'updated' : doiInfo.registered ? 'registered' : 'created'} date: ${dateToUse}`,
|
|
);
|
|
return new Date(dateToUse);
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Makes a DOI unfindable (registered but not discoverable)
|
|
* Note: DOIs cannot be deleted, only made unfindable
|
|
* await doiClient.makeDoiUnfindable('10.21388/tethys.231');
|
|
*
|
|
* @param doiValue The DOI identifier e.g. '10.5072/tethys.999'
|
|
* @returns Promise<AxiosResponse<any>> The http response
|
|
*/
|
|
public async makeDoiUnfindable(doiValue: string): Promise<AxiosResponse<any>> {
|
|
const auth = {
|
|
username: this.username,
|
|
password: this.password,
|
|
};
|
|
|
|
try {
|
|
// First, check if DOI exists
|
|
const exists = await this.doiExists(doiValue);
|
|
if (!exists) {
|
|
throw new DoiClientException(404, `DOI ${doiValue} not found`);
|
|
}
|
|
|
|
// Delete the DOI URL mapping to make it unfindable
|
|
// This removes the URL but keeps the metadata registered
|
|
const response = await axios.delete(`${this.serviceUrl}/doi/${doiValue}`, { auth });
|
|
|
|
// Response Codes for DELETE /doi/{doi}
|
|
// 200 OK: operation successful
|
|
// 401 Unauthorized: no login
|
|
// 403 Forbidden: login problem, quota exceeded
|
|
// 404 Not Found: DOI does not exist
|
|
if (response.status !== 200) {
|
|
const message = `Unexpected DataCite MDS response code ${response.status}`;
|
|
logger.error(message);
|
|
throw new DoiClientException(response.status, message);
|
|
}
|
|
|
|
logger.info(`DOI ${doiValue} successfully made unfindable`);
|
|
return response;
|
|
} catch (error) {
|
|
logger.error(`Failed to make DOI ${doiValue} unfindable: ${error.message}`);
|
|
if (error instanceof DoiClientException) {
|
|
throw error;
|
|
}
|
|
throw new DoiClientException(error.response?.status || 500, error.response?.data || error.message);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Makes a DOI findable again by re-registering the URL
|
|
* await doiClient.makeDoiFindable(
|
|
* '10.21388/tethys.231',
|
|
* 'https://doi.dev.tethys.at/10.21388/tethys.231'
|
|
* );
|
|
*
|
|
* @param doiValue The DOI identifier e.g. '10.5072/tethys.999'
|
|
* @param landingPageUrl The landing page URL
|
|
* @returns Promise<AxiosResponse<any>> The http response
|
|
*/
|
|
public async makeDoiFindable(doiValue: string, landingPageUrl: string): Promise<AxiosResponse<any>> {
|
|
const auth = {
|
|
username: this.username,
|
|
password: this.password,
|
|
};
|
|
|
|
try {
|
|
// Re-register the DOI with its URL to make it findable again
|
|
const response = await axios.put(`${this.serviceUrl}/doi/${doiValue}`, `doi=${doiValue}\nurl=${landingPageUrl}`, { auth });
|
|
|
|
// Response Codes for PUT /doi/{doi}
|
|
// 201 Created: operation successful
|
|
// 400 Bad Request: request body must be exactly two lines: DOI and URL
|
|
// 401 Unauthorized: no login
|
|
// 403 Forbidden: login problem, quota exceeded
|
|
// 412 Precondition failed: metadata must be uploaded first
|
|
if (response.status !== 201) {
|
|
const message = `Unexpected DataCite MDS response code ${response.status}`;
|
|
logger.error(message);
|
|
throw new DoiClientException(response.status, message);
|
|
}
|
|
|
|
logger.info(`DOI ${doiValue} successfully made findable again`);
|
|
return response;
|
|
} catch (error) {
|
|
logger.error(`Failed to make DOI ${doiValue} findable: ${error.message}`);
|
|
if (error instanceof DoiClientException) {
|
|
throw error;
|
|
}
|
|
throw new DoiClientException(error.response?.status || 500, error.response?.data || error.message);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gets the current state of a DOI (draft, registered, findable)
|
|
* const state = await doiClient.getDoiState('10.21388/tethys.231');
|
|
* console.log(`Current state: ${state}`); // 'findable'
|
|
*
|
|
* @param doiValue The DOI identifier
|
|
* @returns Promise<string | null> The DOI state or null if not found
|
|
*/
|
|
public async getDoiState(doiValue: string): Promise<string | null> {
|
|
const doiInfo = await this.getDoiInfo(doiValue);
|
|
return doiInfo?.state || null;
|
|
}
|
|
}
|