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.
1,010 lines (902 loc) • 38.6 kB
JavaScript
/**
* EUDR Submission Service Client (using axios for raw XML)
*
* This module provides a reusable class for connecting to the EUDR Submission Service
* with proper WSSE security headers using direct XML and HTTP requests.
* This implementation uses axios for HTTP requests and builds the SOAP
* envelope manually, which might be useful if the SOAP libraries don't
* work as expected.
*
* Automatic endpoint generation:
* - For webServiceClientId 'eudr-repository': production environment
* - For webServiceClientId 'eudr-test': acceptance environment
* - For custom webServiceClientId: endpoint must be provided manually
*/
// Required dependencies
const axios = require('axios');
const crypto = require('node:crypto');
const { v4: uuidv4 } = require('uuid');
const { parseString } = require('xml2js');
const EudrErrorHandler = require('../utils/error-handler');
const { logger } = require('../utils/logger');
const { validateAndGenerateEndpoint } = require('../utils/endpoint-utils');
/**
* EUDR Submission Service Client class
*/
class EudrSubmissionClient {
/**
* Create a new EUDR Submission 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 EudrSubmissionClient({
* username: 'user',
* password: 'pass',
* webServiceClientId: 'eudr-test'
* });
*
* @example
* // Manual endpoint override
* const client = new EudrSubmissionClient({
* 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, 'submission', '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) {
logger.debug("PASSWORD", 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');
}
/**
* Encode plain GeoJSON strings to base64 in request
* @private
* @param {Object} request - The DDS submission request object
*/
encodeGeojsonInRequest(request) {
if (!request.statement || !request.statement.commodities) {
return;
}
const processCommodities = (commodities) => {
if (!Array.isArray(commodities)) {
commodities = [commodities];
}
commodities.forEach(commodity => {
if (commodity.producers && Array.isArray(commodity.producers)) {
commodity.producers.forEach(producer => {
if (producer.geometryGeojson) {
// Handle different input types
if (typeof producer.geometryGeojson === 'object') {
// If it's an object, stringify it first
try {
producer.geometryGeojson = Buffer.from(JSON.stringify(producer.geometryGeojson), 'utf-8').toString('base64');
} catch (error) {
logger.warn('Invalid GeoJSON object, skipping encoding:', error.message);
}
} else if (typeof producer.geometryGeojson === 'string') {
try {
// Check if it's already base64 encoded
const decoded = Buffer.from(producer.geometryGeojson, 'base64').toString('utf-8');
JSON.parse(decoded); // Try to parse as JSON
// If successful, it's already base64 encoded, do nothing
} catch (error) {
// If it fails, it's a plain JSON string, encode it
try {
JSON.parse(producer.geometryGeojson); // Validate it's valid JSON
producer.geometryGeojson = Buffer.from(producer.geometryGeojson, 'utf-8').toString('base64');
} catch (jsonError) {
// If it's not valid JSON, leave it as is
logger.warn('Invalid GeoJSON format, skipping encoding:', jsonError.message);
}
}
}
}
});
}
});
};
processCommodities(request.statement.commodities);
}
/**
* Create SOAP envelope for the submitDds operation
* @private
* @param {Object} request - The DDS submission request object
* @returns {string} Complete SOAP envelope as XML string
*/
createSoapEnvelope(request) {
// 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:v1="http://ec.europa.eu/tracesnt/certificate/eudr/submission/v1"
xmlns:v11="http://ec.europa.eu/tracesnt/certificate/eudr/model/v1"
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"
soapenv:mustUnderstand="1">
<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>
<v1:SubmitStatementRequest>
<v1:operatorType>${request.operatorType}</v1:operatorType>
<v1:statement>
${this.generateStatementXml(request.statement)}
</v1:statement>
</v1:SubmitStatementRequest>
</soapenv:Body>
</soapenv:Envelope>`;
}
/**
* Create SOAP envelope for the amendDds operation
* @private
* @param {string} ddsIdentifier - UUID of the DDS to amend
* @param {Object} statement - The updated DDS statement
* @returns {string} Complete SOAP envelope as XML string
*/
createAmendSoapEnvelope(ddsIdentifier, statement) {
// 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:v1="http://ec.europa.eu/tracesnt/certificate/eudr/submission/v1"
xmlns:v11="http://ec.europa.eu/tracesnt/certificate/eudr/model/v1"
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"
soapenv:mustUnderstand="1">
<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>
<v1:AmendStatementRequest>
<v1:ddsIdentifier>${ddsIdentifier}</v1:ddsIdentifier>
<v1:statement>
${this.generateStatementXml(statement)}
</v1:statement>
</v1:AmendStatementRequest>
</soapenv:Body>
</soapenv:Envelope>`;
}
/**
* Generate XML for the statement part of the request
* @private
* @param {Object} statement - The statement object
* @returns {string} Statement XML
*/
generateStatementXml(statement) {
let xml = '';
logger.debug('statement01', statement);
// Add required fields in correct order - with validation
if (!statement.internalReferenceNumber) {
throw new Error('internalReferenceNumber is required');
}
xml += `<v11:internalReferenceNumber>${statement.internalReferenceNumber}</v11:internalReferenceNumber>`;
if (!statement.activityType) {
throw new Error('activityType is required');
}
xml += `<v11:activityType>${statement.activityType}</v11:activityType>`;
// Add operator details IMMEDIATELY after activityType (V1 XSD schema requirement - line 53)
if (statement.operator) {
xml += '<v11:operator>';
if (statement.operator.referenceNumber) {
const refArray = Array.isArray(statement.operator.referenceNumber) ? statement.operator.referenceNumber : [statement.operator.referenceNumber];
for (const ref of refArray) {
xml += '<v11:referenceNumber>';
xml += `<v11:identifierType>${ref.identifierType}</v11:identifierType>`;
xml += `<v11:identifierValue>${ref.identifierValue}</v11:identifierValue>`;
xml += '</v11:referenceNumber>';
}
}
if (statement.operator.nameAndAddress) {
xml += '<v11:nameAndAddress>';
xml += `<v4:name>${statement.operator.nameAndAddress.name}</v4:name>`;
xml += `<v4:country>${statement.operator.nameAndAddress.country}</v4:country>`;
xml += `<v4:address>${statement.operator.nameAndAddress.address}</v4:address>`;
xml += '</v11:nameAndAddress>';
}
if (statement.operator.email) {
xml += `<v11:email>${statement.operator.email}</v11:email>`;
}
if (statement.operator.phone) {
xml += `<v11:phone>${statement.operator.phone}</v11:phone>`;
}
xml += '</v11:operator>';
}
// Add optional fields in correct order after operator
if (statement.countryOfActivity) {
xml += `<v11:countryOfActivity>${statement.countryOfActivity}</v11:countryOfActivity>`;
}
if (statement.borderCrossCountry) {
xml += `<v11:borderCrossCountry>${statement.borderCrossCountry}</v11:borderCrossCountry>`;
}
if (statement.countryOfEntry) {
xml += `<v11:countryOfEntry>${statement.countryOfEntry}</v11:countryOfEntry>`;
}
if (statement.comment) {
xml += `<v11:comment>${statement.comment}</v11:comment>`;
}
// Add commodities after all other elements, but before geoLocationConfidential
if (statement.commodities) {
for (const commodity of Array.isArray(statement.commodities) ? statement.commodities : [statement.commodities]) {
xml += '<v11:commodities>';
xml += this.generateCommodityXml(commodity);
xml += '</v11:commodities>';
}
}
// Add geoLocationConfidential flag
xml += `<v11:geoLocationConfidential>${statement.geoLocationConfidential || false}</v11:geoLocationConfidential>`;
// Add associated statements if present (support both object and array)
if (statement.associatedStatements) {
const assocArray = Array.isArray(statement.associatedStatements) ? statement.associatedStatements : [statement.associatedStatements];
for (const assoc of assocArray) {
xml += '<v11:associatedStatements>';
xml += `<v11:referenceNumber>${assoc.referenceNumber}</v11:referenceNumber>`;
if (assoc.verificationNumber) {
xml += `<v11:verificationNumber>${assoc.verificationNumber}</v11:verificationNumber>`;
}
xml += '</v11:associatedStatements>';
}
}
logger.debug('xml', xml);
return xml;
}
/**
* Generate XML for a commodity
* @private
* @param {Object} commodity - The commodity object
* @returns {string} Commodity XML
*/
generateCommodityXml(commodity) {
let xml = '';
// Add descriptors
if (commodity.descriptors) {
xml += '<v11:descriptors>';
if (commodity.descriptors.descriptionOfGoods) {
xml += `<v11:descriptionOfGoods>${commodity.descriptors.descriptionOfGoods}</v11:descriptionOfGoods>`;
}
if (commodity.descriptors.goodsMeasure) {
xml += '<v11:goodsMeasure>';
const measure = commodity.descriptors.goodsMeasure;
if (measure.volume) xml += `<v11:volume>${measure.volume}</v11:volume>`;
if (measure.netWeight) xml += `<v11:netWeight>${measure.netWeight}</v11:netWeight>`;
if (measure.supplementaryUnit) xml += `<v11:supplementaryUnit>${measure.supplementaryUnit}</v11:supplementaryUnit>`;
if (measure.supplementaryUnitQualifier) xml += `<v11:supplementaryUnitQualifier>${measure.supplementaryUnitQualifier}</v11:supplementaryUnitQualifier>`;
xml += '</v11:goodsMeasure>';
}
xml += '</v11:descriptors>';
}
// Add HS heading
if (commodity.hsHeading) {
xml += `<v11:hsHeading>${commodity.hsHeading}</v11:hsHeading>`;
}
// Add species info (support both object and array)
if (commodity.speciesInfo) {
const speciesInfoArray = Array.isArray(commodity.speciesInfo) ? commodity.speciesInfo : [commodity.speciesInfo];
for (const speciesInfo of speciesInfoArray) {
xml += '<v11:speciesInfo>';
if (speciesInfo.scientificName) {
xml += `<v11:scientificName>${speciesInfo.scientificName}</v11:scientificName>`;
}
if (speciesInfo.commonName) {
xml += `<v11:commonName>${speciesInfo.commonName}</v11:commonName>`;
}
xml += '</v11:speciesInfo>';
}
}
// Add producers
if (commodity.producers) {
for (const producer of Array.isArray(commodity.producers) ? commodity.producers : [commodity.producers]) {
xml += '<v11:producers>';
if (producer.country) xml += `<v11:country>${producer.country}</v11:country>`;
if (producer.name) xml += `<v11:name>${producer.name}</v11:name>`;
if (producer.geometryGeojson) xml += `<v11:geometryGeojson>${producer.geometryGeojson}</v11:geometryGeojson>`;
xml += '</v11:producers>';
}
}
return xml;
}
/**
* Parse XML response to extract the DDS identifier
* @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 DDS identifier from the response
const envelope = result['S:Envelope'] || result['soapenv:Envelope'];
const body = envelope['S:Body'] || envelope['soapenv:Body'];
// Find response with flexible namespace support
const responseKeys = Object.keys(body);
const submitResponseKey = responseKeys.find(key => key.endsWith(':SubmitStatementResponse'));
const response = body[submitResponseKey];
// Find ddsIdentifier with flexible namespace support
const ddsIdentifierKey = response ? Object.keys(response).find(key => key.endsWith(':ddsIdentifier')) : null;
const ddsIdentifier = response && ddsIdentifierKey ? response[ddsIdentifierKey] : null;
resolve({
raw: xmlResponse,
parsed: result,
ddsIdentifier: ddsIdentifier
});
} catch (error) {
reject(new Error(`Failed to extract DDS identifier from response: ${error.message}`));
}
});
});
}
/**
* Parse XML response from amendDds operation
* @private
* @param {string} xmlResponse - XML response from the service
* @returns {Promise<Object>} Parsed response object
*/
parseAmendResponse(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 response information from the SOAP response
const envelope = result['S:Envelope'] || result['SOAP-ENV:Envelope'] || result['soapenv:Envelope'];
const body = envelope['S:Body'] || envelope['SOAP-ENV:Body'] || envelope['soapenv:Body'];
// For amend, success is indicated by HTTP 200 status with no fault element
resolve({
raw: xmlResponse,
parsed: result,
success: true,
message: 'DDS amended successfully'
});
} catch (error) {
reject(new Error(`Failed to process amend response: ${error.message}`));
}
});
});
}
/**
* Submit a DDS to the EUDR Submission Service
* @param {Object} request - Request object containing the DDS data
* @param {Object} options - Additional options for the request
* @param {boolean} options.rawResponse - Whether to return the raw XML response
* @param {boolean} options.encodeGeojson - Whether to encode plain geometryGeojson strings to base64 (default: false)
* @returns {Promise<Object>} Response object with DDS identifier
*/
async submitDds(request, options = {}) {
try {
logger.debug('DEBUG: Starting submitDds with request:', JSON.stringify(request, null, 2));
// Encode GeoJSON if requested
if (options.encodeGeojson) {
this.encodeGeojsonInRequest(request);
}
// Create SOAP envelope
logger.debug('DEBUG: Creating SOAP envelope...');
const soapEnvelope = this.createSoapEnvelope(request);
logger.debug('DEBUG: SOAP envelope created, length:', soapEnvelope.length);
// Send the request
logger.debug('DEBUG: Sending request to:', this.config.endpoint);
logger.debug('DEBUG: Timeout:', this.config.timeout);
logger.debug('DEBUG: SOAP envelope preview (first 200 chars):', soapEnvelope.substring(0, 200));
const response = await axios({
method: 'post',
url: this.config.endpoint,
headers: {
'Content-Type': 'text/xml;charset=UTF-8',
'SOAPAction': 'http://ec.europa.eu/tracesnt/certificate/eudr/submission/v1'
},
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);
return {
httpStatus: response.status,
status: response.status,
...parsedResponse
};
}
catch (error) {
logger.debug('DEBUG: Error in submitDds:', error.message);
logger.debug('DEBUG: Error type:', error.constructor.name);
logger.debug('DEBUG: Error stack:', error.stack);
// Log detailed error information
if (error.response) {
logger.debug('DEBUG: Error has response - Status:', error.response.status);
logger.debug('DEBUG: Error has response - StatusText:', error.response.statusText);
logger.debug('DEBUG: Error has response - Data length:', error.response.data?.length || 0);
logger.debug('DEBUG: Error has response - Data type:', typeof error.response.data);
if (error.response.data) {
logger.debug('DEBUG: Error response data (first 1500 chars):', error.response.data.substring(0, 1500));
}
} else if (error.request) {
logger.debug('DEBUG: Error has request but no response');
logger.debug('DEBUG: Request details:', error.request);
} else {
logger.debug('DEBUG: Error has neither response nor request');
}
// Log additional error properties
logger.debug('DEBUG: Error code:', error.code);
logger.debug('DEBUG: Error config:', error.config);
logger.debug('DEBUG: Error isAxiosError:', error.isAxiosError);
// Use the error handler if available
if (EudrErrorHandler) {
logger.debug('DEBUG: Using EudrErrorHandler...');
const handledError = EudrErrorHandler.handleError(error);
// Preserve the original error properties for the controller
handledError.originalError = error;
throw handledError;
}
// Default error handling if error handler is not available
const errorResponse = {
error: true,
message: error.message,
details: {}
};
if (error.response) {
// The request was made and the server responded with a status code
// that falls out of the range of 2xx
errorResponse.details = {
httpStatus: error.response.status,
status: error.response.status,
statusText: error.response.statusText,
data: error.response.data
};
}
else if (error.request) {
// The request was made but no response was received
errorResponse.details = {
request: 'Request sent but no response received'
};
}
throw errorResponse;
}
}
/**
* Amend an existing DDS in the EUDR Submission Service
* @param {string} ddsIdentifier - UUID of the DDS to amend
* @param {Object} statement - The updated DDS statement
* @param {Object} options - Additional options for the request
* @param {boolean} options.rawResponse - Whether to return the raw XML response
* @param {boolean} options.encodeGeojson - Whether to encode plain geometryGeojson strings to base64 (default: false)
* @returns {Promise<Object>} Response object indicating success
*/
async amendDds(ddsIdentifier, statement, options = {}) {
try {
// Encode GeoJSON if requested
if (options.encodeGeojson) {
this.encodeGeojsonInRequest({ statement });
}
// Create SOAP envelope
const soapEnvelope = this.createAmendSoapEnvelope(ddsIdentifier, statement);
// 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/certificate/eudr/submission/v1#amendDds'
},
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.parseAmendResponse(response.data);
return {
httpStatus: response.status,
status: response.status,
...parsedResponse
};
}
catch (error) {
// Use the error handler if available
if (EudrErrorHandler) {
throw EudrErrorHandler.handleError(error);
}
// Default error handling if error handler is not available
const errorResponse = {
error: true,
message: error.message,
details: {}
};
if (error.response) {
// The request was made and the server responded with a status code
// that falls out of the range of 2xx
errorResponse.details = {
httpStatus: error.response.status,
status: error.response.status,
statusText: error.response.statusText,
data: error.response.data
};
}
else if (error.request) {
// The request was made but no response was received
errorResponse.details = {
request: 'Request sent but no response received'
};
}
throw errorResponse;
}
}
/**
* Create SOAP envelope for the retractDds operation
* @private
* @param {string} ddsIdentifier - UUID of the DDS to retract
* @returns {string} Complete SOAP envelope as XML string
*/
createRetractSoapEnvelope(ddsIdentifier) {
// 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()}`;
return `<?xml version="1.0" encoding="UTF-8"?>
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/"
xmlns:v1="http://ec.europa.eu/tracesnt/certificate/eudr/submission/v1"
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"
soapenv:mustUnderstand="1">
<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>
<v1:RetractStatementRequest>
<v1:ddsIdentifier>${ddsIdentifier}</v1:ddsIdentifier>
</v1:RetractStatementRequest>
</soapenv:Body>
</soapenv:Envelope>`;
}
/**
* Parse XML response from retractDds operation
* @private
* @param {string} xmlResponse - XML response from the service
* @returns {Promise<Object>} Parsed response object
*/
parseRetractResponse(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 response information from the SOAP response
const envelope = result['S:Envelope'] || result['SOAP-ENV:Envelope'] || result['soapenv:Envelope'];
if (!envelope) {
throw new Error('Invalid response: Missing Envelope element');
}
const body = envelope['S:Body'] || envelope['SOAP-ENV:Body'] || envelope['soapenv:Body'];
if (!body) {
throw new Error('Invalid response: Missing Body element');
}
// Find the RetractStatementResponse element regardless of namespace prefix
let response = null;
let status = null;
// Look for any key that ends with 'RetractStatementResponse'
for (const key in body) {
if (key.endsWith(':RetractStatementResponse') || key === 'RetractStatementResponse') {
response = body[key];
break;
}
}
if (!response) {
// If we can't find the response element, try to extract raw response data
logger.debug('Raw XML response:', xmlResponse);
throw new Error('Invalid response: Missing RetractStatementResponse element');
}
// Look for status field with any namespace prefix
for (const key in response) {
if (key.endsWith(':status') || key === 'status') {
status = response[key];
break;
}
}
const success = status === 'SC_200_OK';
resolve({
raw: xmlResponse,
parsed: result,
success,
status,
message: success ? 'DDS retracted successfully' : `Retract failed with status: ${status || 'unknown'}`
});
} catch (error) {
// Include the raw response in the error for debugging
const errorMsg = `Failed to process retract response: ${error.message}`;
logger.error(errorMsg);
logger.error('Raw response:', xmlResponse);
reject(new Error(errorMsg));
}
});
});
}
/**
* Retract a DDS from the EUDR Submission Service
* @param {string} ddsIdentifier - UUID of the DDS to retract
* @param {Object} options - Additional options for the request
* @returns {Promise<Object>} Response object indicating success
* @throws {Error} If the request fails or returns an error
*/
async retractDds(ddsIdentifier, options = {}) {
try {
if (!ddsIdentifier) {
throw new Error('DDS identifier is required');
}
// Create SOAP envelope
const soapEnvelope = this.createRetractSoapEnvelope(ddsIdentifier);
// Enable debug logging if requested
if (options.debug) {
logger.debug('Request SOAP envelope:');
logger.debug(soapEnvelope);
}
// 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/certificate/eudr/submission/v1#retractDds'
},
data: soapEnvelope,
timeout: this.config.timeout,
httpsAgent: new (require('https').Agent)({
rejectUnauthorized: this.config.ssl
})
});
// Log response for debugging if requested
if (options.debug) {
logger.debug('Response status:', response.status);
logger.debug('Response data:', response.data);
}
// 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.parseRetractResponse(response.data);
return {
httpStatus: response.status,
status: response.status,
...parsedResponse
};
}
catch (error) {
// Use the error handler if available
if (EudrErrorHandler) {
throw EudrErrorHandler.handleError(error);
}
// Default error handling if error handler is not available
const errorResponse = {
error: true,
message: error.message,
details: {}
};
if (error.response) {
// The request was made and the server responded with a status code
// that falls out of the range of 2xx
errorResponse.details = {
httpStatus: error.response.status,
status: error.response.status,
statusText: error.response.statusText,
data: error.response.data
};
}
else if (error.request) {
// The request was made but no response was received
errorResponse.details = {
request: 'Request sent but no response received'
};
}
throw errorResponse;
}
}
}
// Export the class
module.exports = EudrSubmissionClient;
// Example usage when run directly
if (require.main === module) {
const scenarios = require('./scenarios');
// Default configuration
const defaultConfig = {
username: 'n00ihxdy',
password: '5YXQfrM9XSzS00FTSUPWT7scGEHLrd2NBc0DDJb6',
webServiceClientId: 'eudr-test'
};
async function main() {
try {
// Create client instance
const client = new EudrSubmissionClient(defaultConfig);
// Get the test scenario from command-line arguments or use default
const scenarioName = process.argv[2] || 'domestic';
const request = scenarios[scenarioName.toLowerCase()] || scenarios.import;
logger.info(`\nPreparing ${scenarioName} scenario SOAP request...`);
logger.info('Sending request to EUDR Submission Service...');
// Submit the DDS
const result = await client.submitDds(request);
logger.info('\nResponse received:');
logger.info('HTTP Status:', result.httpStatus);
logger.info('DDS Identifier:', result.ddsIdentifier);
logger.info('\nRaw XML Response (first 150 chars):', result.raw.substring(0, 150) + '...');
// Print usage instructions
logger.info('\nKorištenje:');
logger.info(' node eudr-submission-client-axios.js [scenarij]');
logger.info('\nDostupni scenariji:');
logger.info(' import - Scenarij uvoza s geolokacijom');
logger.info(' trade - Scenarij trgovine s referenciranim DDS-om');
logger.info(' representative - Scenarij ovlaštenog predstavnika');
logger.info(' domestic - Domaći scenarij s geolokacijom');
} catch (error) {
logger.error('Error in main function:');
logger.error(JSON.stringify(error, null, 2));
}
}
// Run the main function
main();
}