404 lines
12 KiB
TypeScript
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;
|
|
}
|
|
}
|