Assist_Design/apps/bff/src/integrations/japanpost/services/japanpost-connection.service.ts

404 lines
12 KiB
TypeScript

/**
* Japan Post Connection Service
*
* HTTP client for Japan Post Digital Address API with OAuth token management.
*
* Required Environment Variables:
* JAPAN_POST_API_URL - Base URL for Japan Post Digital Address API
* JAPAN_POST_CLIENT_ID - OAuth client ID
* JAPAN_POST_CLIENT_SECRET - OAuth client secret
*
* Optional Environment Variables:
* JAPAN_POST_TIMEOUT - Request timeout in ms (default: 10000)
*/
import { Injectable, Inject, type OnModuleInit } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Logger } from "nestjs-pino";
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
import {
japanPostTokenResponseSchema,
type JapanPostTokenResponse,
} from "@customer-portal/domain/address/providers";
interface JapanPostConfig {
baseUrl: string;
clientId: string;
clientSecret: string;
timeout: number;
}
interface ConfigValidationError {
envVar: string;
message: string;
}
/**
* Japan Post API error response format
*/
interface JapanPostErrorResponse {
request_id?: string;
error_code?: string;
message?: string;
}
@Injectable()
export class JapanPostConnectionService implements OnModuleInit {
private accessToken: string | null = null;
private tokenExpiresAt: number = 0;
private readonly config: JapanPostConfig;
private readonly configErrors: ConfigValidationError[];
constructor(
private readonly configService: ConfigService,
@Inject(Logger) private readonly logger: Logger
) {
this.config = {
baseUrl: this.configService.get<string>("JAPAN_POST_API_URL") || "",
clientId: this.configService.get<string>("JAPAN_POST_CLIENT_ID") || "",
clientSecret: this.configService.get<string>("JAPAN_POST_CLIENT_SECRET") || "",
timeout: this.configService.get<number>("JAPAN_POST_TIMEOUT") || 10000,
};
// Validate configuration
this.configErrors = this.validateConfig();
}
/**
* Validate required environment variables
*/
private validateConfig(): ConfigValidationError[] {
const errors: ConfigValidationError[] = [];
if (!this.config.baseUrl) {
errors.push({
envVar: "JAPAN_POST_API_URL",
message: "Missing required environment variable JAPAN_POST_API_URL",
});
} else if (!this.config.baseUrl.startsWith("https://")) {
errors.push({
envVar: "JAPAN_POST_API_URL",
message: "JAPAN_POST_API_URL must use HTTPS",
});
}
if (!this.config.clientId) {
errors.push({
envVar: "JAPAN_POST_CLIENT_ID",
message: "Missing required environment variable JAPAN_POST_CLIENT_ID",
});
}
if (!this.config.clientSecret) {
errors.push({
envVar: "JAPAN_POST_CLIENT_SECRET",
message: "Missing required environment variable JAPAN_POST_CLIENT_SECRET",
});
}
if (this.config.timeout < 1000 || this.config.timeout > 60000) {
errors.push({
envVar: "JAPAN_POST_TIMEOUT",
message: "JAPAN_POST_TIMEOUT must be between 1000ms and 60000ms",
});
}
return errors;
}
/**
* Log configuration status on module initialization
*/
onModuleInit() {
if (this.configErrors.length > 0) {
this.logger.error(
"Japan Post API configuration is invalid. Address lookup will be unavailable.",
{
errors: this.configErrors,
hint: "Add the required environment variables to your .env file",
}
);
} else {
this.logger.log("Japan Post API configured successfully", {
baseUrl: this.config.baseUrl.replace(/\/+$/, ""),
timeout: this.config.timeout,
});
}
}
/**
* Get configuration validation errors (for health checks)
*/
getConfigErrors(): ConfigValidationError[] {
return this.configErrors;
}
/**
* Get a valid access token, refreshing if necessary
*/
private async getAccessToken(): Promise<string> {
// Fail fast if not configured
this.assertConfigured();
const now = Date.now();
// Return cached token if still valid (with 60s buffer)
if (this.accessToken && this.tokenExpiresAt > now + 60000) {
return this.accessToken;
}
this.logger.debug("Acquiring Japan Post access token");
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
const startTime = Date.now();
const tokenUrl = `${this.config.baseUrl}/api/v1/j/token`;
try {
const response = await fetch(tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-forwarded-for": "127.0.0.1", // Required by API
},
body: JSON.stringify({
grant_type: "client_credentials",
client_id: this.config.clientId,
secret_key: this.config.clientSecret,
}),
signal: controller.signal,
});
const durationMs = Date.now() - startTime;
if (!response.ok) {
const errorBody = await response.text().catch(() => "");
const parsedError = this.parseErrorResponse(errorBody);
this.logger.error("Japan Post token request failed", {
endpoint: tokenUrl,
httpStatus: response.status,
httpStatusText: response.statusText,
durationMs,
// Japan Post API error details
requestId: parsedError?.request_id,
errorCode: parsedError?.error_code,
apiMessage: parsedError?.message,
hint: this.getErrorHint(response.status, parsedError?.error_code),
});
throw new Error(`Token request failed: HTTP ${response.status}`);
}
const data = (await response.json()) as JapanPostTokenResponse;
const validated = japanPostTokenResponseSchema.parse(data);
const token = validated.token;
this.accessToken = token;
this.tokenExpiresAt = now + validated.expires_in * 1000;
this.logger.debug("Japan Post token acquired", {
expiresIn: validated.expires_in,
tokenType: validated.token_type,
durationMs,
});
return token;
} catch (error) {
const durationMs = Date.now() - startTime;
const isAborted = error instanceof Error && error.name === "AbortError";
if (isAborted) {
this.logger.error("Japan Post token request timed out", {
endpoint: tokenUrl,
timeoutMs: this.config.timeout,
durationMs,
});
throw new Error(`Token request timed out after ${this.config.timeout}ms`);
}
// Only log if not already logged above (non-ok response)
if (!(error instanceof Error && error.message.startsWith("Token request failed"))) {
this.logger.error("Japan Post token request error", {
endpoint: tokenUrl,
error: extractErrorMessage(error),
durationMs,
});
}
throw error;
} finally {
clearTimeout(timeoutId);
}
}
/**
* Parse Japan Post API error response
*/
private parseErrorResponse(body: string): JapanPostErrorResponse | null {
if (!body) return null;
try {
const parsed = JSON.parse(body) as JapanPostErrorResponse;
// Validate it has the expected shape
if (parsed && typeof parsed === "object") {
return {
request_id: parsed.request_id,
error_code: parsed.error_code,
message: parsed.message,
};
}
return null;
} catch {
return null;
}
}
/**
* Get a helpful hint based on HTTP status code and API error code
*/
private getErrorHint(status: number, errorCode?: string): string {
// Handle specific Japan Post error codes (format: "400-1028-0001")
if (errorCode) {
if (errorCode.startsWith("400-1028")) {
return "Invalid client_id or secret_key - check JAPAN_POST_CLIENT_ID and JAPAN_POST_CLIENT_SECRET";
}
if (errorCode.startsWith("401")) {
return "Token is invalid or expired - will retry with fresh token";
}
if (errorCode.startsWith("404")) {
return "ZIP code not found in Japan Post database";
}
}
// Fall back to HTTP status hints
switch (status) {
case 400:
return "Invalid request - check ZIP code format (7 digits)";
case 401:
return "Token expired or invalid - check credentials";
case 403:
return "Access forbidden - API credentials may be suspended";
case 404:
return "ZIP code not found in Japan Post database";
case 429:
return "Rate limit exceeded - reduce request frequency";
case 500:
return "Japan Post API internal error - retry later";
case 502:
case 503:
case 504:
return "Japan Post API is temporarily unavailable - retry later";
default:
return "Unexpected error - check API configuration";
}
}
/**
* Search addresses by ZIP code
*
* @param zipCode - 7-digit ZIP code (no hyphen)
* @returns Raw Japan Post API response
*/
async searchByZipCode(zipCode: string): Promise<unknown> {
const token = await this.getAccessToken();
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
const startTime = Date.now();
const url = `${this.config.baseUrl}/api/v1/searchcode/${zipCode}`;
this.logger.debug("Japan Post ZIP code search started", { zipCode });
try {
const response = await fetch(url, {
method: "GET",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
signal: controller.signal,
});
const durationMs = Date.now() - startTime;
if (!response.ok) {
const errorBody = await response.text().catch(() => "");
const parsedError = this.parseErrorResponse(errorBody);
this.logger.error("Japan Post ZIP search request failed", {
zipCode,
endpoint: url,
httpStatus: response.status,
httpStatusText: response.statusText,
durationMs,
// Japan Post API error details
requestId: parsedError?.request_id,
errorCode: parsedError?.error_code,
apiMessage: parsedError?.message,
hint: this.getErrorHint(response.status, parsedError?.error_code),
});
throw new Error(`ZIP code search failed: HTTP ${response.status}`);
}
const data = await response.json();
this.logger.debug("Japan Post ZIP search completed", {
zipCode,
resultCount: (data as { count?: number }).count,
durationMs,
});
return data;
} catch (error) {
const durationMs = Date.now() - startTime;
const isAborted = error instanceof Error && error.name === "AbortError";
if (isAborted) {
this.logger.error("Japan Post ZIP search timed out", {
zipCode,
endpoint: url,
timeoutMs: this.config.timeout,
durationMs,
});
throw new Error(`ZIP search timed out after ${this.config.timeout}ms`);
}
// Only log if not already logged above (non-ok response)
if (!(error instanceof Error && error.message.startsWith("ZIP code search failed"))) {
this.logger.error("Japan Post ZIP search error", {
zipCode,
endpoint: url,
error: extractErrorMessage(error),
durationMs,
});
}
throw error;
} finally {
clearTimeout(timeoutId);
}
}
/**
* Check if the service is properly configured
*/
isConfigured(): boolean {
return this.configErrors.length === 0;
}
/**
* Throw an error if configuration is invalid
*/
private assertConfigured(): void {
if (this.configErrors.length > 0) {
const missingVars = this.configErrors.map(e => e.envVar).join(", ");
throw new Error(`Japan Post API is not configured. Missing or invalid: ${missingVars}`);
}
}
/**
* Clear cached token (for testing or forced refresh)
*/
clearTokenCache(): void {
this.accessToken = null;
this.tokenExpiresAt = 0;
}
}