Add Binect SDK implementation, Explorer, and test suite

SDK (@binect/js):
- BinectClient with domain sub-clients (documents, sendings, accounts,
  attachments, invoices)
- HTTP Basic Auth, native fetch only (no runtime dependencies)
- TypeScript types matching Binect API vocabulary
- Status predicates and polling helpers in helpers.ts
- Structured error handling (BinectApiError, BinectAuthError)

Explorer:
- Standalone browser-based API explorer (explorer/index.html)
- Interactive testing without code

Tests:
- Unit tests for client, types, errors, helpers, http
- E2E tests for upload/delete and send/cancel workflows

Also includes:
- Architecture Decision Records (ADRs)
- Example DIN 5008 letter PDFs for testing
- API specification research notes

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-14 23:10:34 +01:00
parent 20462b48b3
commit b9aebb42f1
34 changed files with 6499 additions and 20 deletions

274
src/helpers.ts Normal file
View File

@@ -0,0 +1,274 @@
/**
* Convenience Layer - Optional Helpers
*
* These helpers are purely additive and do not replace core API methods.
* They provide convenient predicates and utilities for common operations.
*/
import { DocumentStatus, type Document, type Sending, type ValidationMessage } from './types.js';
/**
* Helper to get status code from document or sending
*/
function getStatusCode(doc: Document | Sending): DocumentStatus {
// Document has status.code, Sending might have status directly
if (typeof doc.status === 'object' && 'code' in doc.status) {
return doc.status.code;
}
return doc.status as unknown as DocumentStatus;
}
// ============================================================================
// Status Predicates
// ============================================================================
/**
* Check if a document is shippable (status 2).
*/
export function isShippable(doc: Document | Sending): boolean {
return getStatusCode(doc) === DocumentStatus.SHIPPABLE;
}
/**
* Check if a document has errors (status 7).
*/
export function isErroneous(doc: Document | Sending): boolean {
return getStatusCode(doc) === DocumentStatus.ERRONEOUS;
}
/**
* Check if a document is still being prepared (status 1).
*/
export function isInPreparation(doc: Document | Sending): boolean {
return getStatusCode(doc) === DocumentStatus.IN_PREPARATION;
}
/**
* Check if a document is in the production queue (status 3).
*/
export function isInProductionQueue(doc: Document | Sending): boolean {
return getStatusCode(doc) === DocumentStatus.PRODUCTION_QUEUE;
}
/**
* Check if a document is currently being printed (status 4).
*/
export function isPrinting(doc: Document | Sending): boolean {
return getStatusCode(doc) === DocumentStatus.PRINTING;
}
/**
* Check if a document has been sent (status 5).
*/
export function isSent(doc: Document | Sending): boolean {
return getStatusCode(doc) === DocumentStatus.SENT;
}
/**
* Check if a document was canceled (status 6).
*/
export function isCanceled(doc: Document | Sending): boolean {
return getStatusCode(doc) === DocumentStatus.CANCELED;
}
/**
* Check if a document is in a terminal state (sent, canceled, or erroneous).
*/
export function isTerminal(doc: Document | Sending): boolean {
const status = getStatusCode(doc);
return (
status === DocumentStatus.SENT ||
status === DocumentStatus.CANCELED ||
status === DocumentStatus.ERRONEOUS
);
}
/**
* Check if a document can still be canceled (in queue or printing).
*/
export function isCancelable(doc: Document | Sending): boolean {
const status = getStatusCode(doc);
return (
status === DocumentStatus.PRODUCTION_QUEUE ||
status === DocumentStatus.PRINTING
);
}
// ============================================================================
// Validation Helpers
// ============================================================================
/**
* Get all validation messages from a document.
*/
function getValidationMessages(doc: Document): ValidationMessage[] {
return doc.letter?.errors ?? [];
}
/**
* Extract error messages from validation results.
*/
export function getErrors(doc: Document): ValidationMessage[] {
return getValidationMessages(doc).filter((m: ValidationMessage) => m.type === 'ERROR');
}
/**
* Extract warning messages from validation results.
*/
export function getWarnings(doc: Document): ValidationMessage[] {
return getValidationMessages(doc).filter((m: ValidationMessage) => m.type === 'WARNING');
}
/**
* Extract info messages from validation results.
*/
export function getInfoMessages(doc: Document): ValidationMessage[] {
return getValidationMessages(doc).filter((m: ValidationMessage) => m.type === 'INFO');
}
/**
* Check if document has any validation errors.
*/
export function hasErrors(doc: Document): boolean {
return getErrors(doc).length > 0;
}
/**
* Check if document has any validation warnings.
*/
export function hasWarnings(doc: Document): boolean {
return getWarnings(doc).length > 0;
}
// ============================================================================
// Status Description
// ============================================================================
/**
* Get human-readable description of document status.
*/
export function getStatusDescription(status: DocumentStatus): string {
switch (status) {
case DocumentStatus.IN_PREPARATION:
return 'In preparation';
case DocumentStatus.SHIPPABLE:
return 'Ready to ship';
case DocumentStatus.PRODUCTION_QUEUE:
return 'In production queue';
case DocumentStatus.PRINTING:
return 'Printing';
case DocumentStatus.SENT:
return 'Sent';
case DocumentStatus.CANCELED:
return 'Canceled';
case DocumentStatus.ERRONEOUS:
return 'Has errors';
default:
return 'Unknown status';
}
}
// ============================================================================
// Base64 Utilities
// ============================================================================
/**
* Encode a file/blob to base64 string (browser environment).
* Returns a promise that resolves to the base64-encoded content.
*/
export function fileToBase64(file: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (): void => {
const result = reader.result as string;
// Remove data URL prefix (e.g., "data:application/pdf;base64,")
const base64 = result.split(',')[1];
if (base64) {
resolve(base64);
} else {
reject(new Error('Failed to extract base64 content'));
}
};
reader.onerror = (): void => reject(reader.error);
reader.readAsDataURL(file);
});
}
/**
* Encode a Buffer to base64 string (Node.js environment).
*/
export function bufferToBase64(buffer: Buffer): string {
return buffer.toString('base64');
}
// ============================================================================
// Polling Utilities (Opt-in, no default behavior)
// ============================================================================
/**
* Options for polling operations.
*/
export interface PollOptions {
/** Interval between polls in milliseconds (default: 2000) */
intervalMs?: number;
/** Maximum number of poll attempts (default: 30) */
maxAttempts?: number;
/** Abort signal for cancellation */
signal?: AbortSignal;
}
/**
* Poll until a condition is met.
* This is an opt-in utility - no automatic polling occurs.
*
* @param fn - Function to poll that returns the current state
* @param condition - Condition to check against the result
* @param options - Polling options
* @returns The final result when condition is met
* @throws Error if max attempts exceeded or aborted
*/
export async function pollUntil<T>(
fn: () => Promise<T>,
condition: (result: T) => boolean,
options: PollOptions = {}
): Promise<T> {
const intervalMs = options.intervalMs ?? 2000;
const maxAttempts = options.maxAttempts ?? 30;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
if (options.signal?.aborted) {
throw new Error('Polling aborted');
}
const result = await fn();
if (condition(result)) {
return result;
}
if (attempt < maxAttempts - 1) {
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
}
throw new Error(`Polling exceeded maximum attempts (${maxAttempts})`);
}
/**
* Wait for a document to reach a shippable state.
* Convenience wrapper around pollUntil.
*
* @param getDocument - Function that fetches the document
* @param options - Polling options
* @returns The document when it becomes shippable or erroneous
*/
export async function waitForShippable(
getDocument: () => Promise<Document>,
options: PollOptions = {}
): Promise<Document> {
return pollUntil(
getDocument,
(doc) => isShippable(doc) || isErroneous(doc),
options
);
}