UNPKG

eudr-api-client

Version:

Enterprise-grade Node.js library for the EU Deforestation Regulation (EUDR) TRACES system. It provides seamless integration for submitting, amending, retrieving, and managing Due Diligence Statements (DDS) with support for both V1 and V2 APIs.

340 lines (299 loc) 11.2 kB
/** * EUDR Echo Service Client (using axios for raw XML) * * This module provides a reusable class for connecting to the EUDR Echo Service * with proper WSSE security headers using direct XML and HTTP requests. * * Automatic endpoint generation: * - For webServiceClientId 'eudr-repository': production environment * - For webServiceClientId 'eudr-test': acceptance environment * - For custom webServiceClientId: endpoint must be provided manually */ const axios = require('axios'); const crypto = require('node:crypto'); const { v4: uuidv4 } = require('uuid'); const { parseString } = require('xml2js'); const { validateAndGenerateEndpoint } = require('../utils/endpoint-utils'); /** * EUDR Echo Service Client class */ class EudrEchoClient { /** * Create a new EUDR Echo Service client * @param {Object} config - Configuration object * @param {string} [config.endpoint] - Service endpoint URL (optional for standard webServiceClientId: 'eudr-repository', 'eudr-test') * @param {string} config.username - Authentication username * @param {string} config.password - Authentication password * @param {string} config.webServiceClientId - Client ID ('eudr-repository', 'eudr-test', or custom) * @param {number} [config.timestampValidity=60] - Timestamp validity in seconds * @param {number} [config.timeout=10000] - Request timeout in milliseconds * @param {boolean} [config.ssl=false] - SSL configuration: true for secure (default), false to allow unauthorized certificates * * @example * // Automatic endpoint generation for standard client IDs * const client = new EudrEchoClient({ * username: 'user', * password: 'pass', * webServiceClientId: 'eudr-test' * }); * * @example * // Manual endpoint override * const client = new EudrEchoClient({ * endpoint: 'https://custom-endpoint.com/ws/service', * username: 'user', * password: 'pass', * webServiceClientId: 'custom-client' * }); */ constructor(config) { // Validate and potentially generate endpoint const validatedConfig = validateAndGenerateEndpoint(config, 'echo', 'v1'); this.config = { // Default configuration timestampValidity: 60, // 1 minute as per requirements timeout: 10000, // 10 seconds timeout ssl: false, // Default to insecure for backward compatibility ...validatedConfig // Override with validated config (includes endpoint) }; // Validate required configuration this.validateConfig(); } /** * Validate that required configuration is provided * @private * @throws {Error} If required configuration is missing */ validateConfig() { const requiredFields = ['endpoint', 'username', 'password', 'webServiceClientId']; for (const field of requiredFields) { if (!this.config[field]) { throw new Error(`Missing required configuration: ${field}`); } } } /** * Generate a random nonce * @private * @returns {Object} Object containing nonce in different formats */ generateNonce() { // Generate 16 random bytes const nonceBytes = crypto.randomBytes(16); // Convert to base64 const nonceBase64 = nonceBytes.toString('base64'); return { bytes: nonceBytes, base64: nonceBase64 }; } /** * Get current timestamp in ISO format * @private * @returns {string} Current timestamp in ISO format */ getCurrentTimestamp() { return new Date().toISOString(); } /** * Get expiration timestamp based on current time plus validity period * @private * @param {number} validityInSeconds - Validity period in seconds * @returns {string} Expiration timestamp in ISO format */ getExpirationTimestamp(validityInSeconds) { const expirationDate = new Date(); expirationDate.setSeconds(expirationDate.getSeconds() + validityInSeconds); return expirationDate.toISOString(); } /** * Generate password digest according to WS-Security standard * @private * @param {Buffer} nonce - Nonce as bytes * @param {string} created - Created timestamp * @param {string} password - Password * @returns {string} Password digest in base64 */ generatePasswordDigest(nonce, created, password) { // Concatenate nonce + created + password const concatenated = Buffer.concat([ nonce, Buffer.from(created), Buffer.from(password) ]); // Create SHA-1 hash const hash = crypto.createHash('sha1').update(concatenated).digest(); // Convert to base64 return hash.toString('base64'); } /** * Create SOAP envelope for the testEcho operation * @private * @param {string} message - Message to echo * @returns {string} Complete SOAP envelope as XML string */ createSoapEnvelope(message) { // Generate required values for security header const nonce = this.generateNonce(); const created = this.getCurrentTimestamp(); const expires = this.getExpirationTimestamp(this.config.timestampValidity); const passwordDigest = this.generatePasswordDigest(nonce.bytes, created, this.config.password); // Generate unique IDs for the security elements const timestampId = `TS-${uuidv4()}`; const usernameTokenId = `UsernameToken-${uuidv4()}`; // Create the complete SOAP envelope return `<?xml version="1.0" encoding="UTF-8"?> <soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:echo="http://ec.europa.eu/tracesnt/eudr/echo" xmlns:v4="http://ec.europa.eu/sanco/tracesnt/base/v4"> <soapenv:Header> <wsse:Security xmlns:wsse="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd" xmlns:wsu="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd"> <wsu:Timestamp wsu:Id="${timestampId}"> <wsu:Created>${created}</wsu:Created> <wsu:Expires>${expires}</wsu:Expires> </wsu:Timestamp> <wsse:UsernameToken wsu:Id="${usernameTokenId}"> <wsse:Username>${this.config.username}</wsse:Username> <wsse:Password Type="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0#PasswordDigest">${passwordDigest}</wsse:Password> <wsse:Nonce EncodingType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-soap-message-security-1.0#Base64Binary">${nonce.base64}</wsse:Nonce> <wsu:Created>${created}</wsu:Created> </wsse:UsernameToken> </wsse:Security> <v4:WebServiceClientId>${this.config.webServiceClientId}</v4:WebServiceClientId> </soapenv:Header> <soapenv:Body> <echo:EudrEchoRequest> <echo:query>${message}</echo:query> </echo:EudrEchoRequest> </soapenv:Body> </soapenv:Envelope>`; } /** * Parse XML response to extract the echo status message * @private * @param {string} xmlResponse - XML response from the service * @returns {Promise<Object>} Parsed response object */ parseResponse(xmlResponse) { return new Promise((resolve, reject) => { parseString(xmlResponse, { explicitArray: false }, (err, result) => { if (err) { reject(new Error(`Failed to parse XML response: ${err.message}`)); return; } try { // Extract the status message from the response const envelope = result['S:Envelope']; const body = envelope['S:Body']; const response = body['ns3:EudrEchoResponse']; // Handle different response formats let status = 'No status message found'; if (response && response.status) { status = response.status; } else if (response && response['ns3:status']) { status = response['ns3:status']; } resolve({ raw: xmlResponse, parsed: result, status: status, httpStatus: status // For consistency, though this might be confusing }); } catch (error) { reject(new Error(`Failed to extract status from response: ${error.message}`)); } }); }); } /** * Call the EUDR Echo Service * @param {string} message - Message to echo * @param {Object} options - Additional options * @param {boolean} options.rawResponse - Whether to return the raw XML response (default: false) * @returns {Promise<Object>} Response object with status and raw XML */ async echo(message, options = {}) { try { // Create SOAP envelope const soapEnvelope = this.createSoapEnvelope(message); // Send the request const response = await axios({ method: 'post', url: this.config.endpoint, headers: { 'Content-Type': 'text/xml;charset=UTF-8', 'SOAPAction': 'http://ec.europa.eu/tracesnt/eudr/echo' }, data: soapEnvelope, timeout: this.config.timeout, httpsAgent: new (require('https').Agent)({ rejectUnauthorized: this.config.ssl }) }); // Return raw response if requested if (options.rawResponse) { return { httpStatus: response.status, status: response.status, data: response.data }; } // Parse the XML response const parsedResponse = await this.parseResponse(response.data); // Remove raw and parsed properties if rawResponse is not requested if (!options.rawResponse) { if (parsedResponse.raw) { delete parsedResponse.raw; } if (parsedResponse.parsed) { delete parsedResponse.parsed; } } return { httpStatus: response.status, ...parsedResponse }; } catch (error) { // Create a more structured error response with proper property order const errorResponse = new Error(error.message); // Set properties in desired order: httpStatus, error, code, details errorResponse.httpStatus = error.response?.status || 500; errorResponse.error = true; // Initialize details object let details = {}; let code = undefined; if (error.response) { details = { status: error.response.status, statusText: error.response.statusText }; // Include raw data only if rawResponse is requested if (options.rawResponse) { details.data = error.response.data; } const errorData = error.response.data; if (errorData.includes('UnauthenticatedException')) { // Set logical status and error code for authentication errors code = 'UNAUTHENTICATED'; details.status = 401; details.statusText = 'Invalid credentials'; } } else if (error.request) { // The request was made but no response was received details = { request: 'Request sent but no response received' }; } // Set code and details in correct order if (code) { errorResponse.code = code; } errorResponse.details = details; throw errorResponse; } } } module.exports = EudrEchoClient;