TypeScript kliens
Ez az oldal egy teljes, függőség nélküli TypeScript klienst ad, amit bemásolhatsz a saját projektedbe. Node 20 vagy újabb kell hozzá: a fetch és a crypto beépített, tehát nincs szükség külső csomagra.
A kód azt a hibakezelést és újrapróbálást valósítja meg, amit az API referencia leír. A folyamat üzleti magyarázata az Integrációs példa oldalon van.
Típusok
Szekció neve “Típusok”Az API válaszainak alakja egységes, ezért néhány típus lefedi az egészet. A nem kitöltött mezők kimaradnak a válaszból, ezért szinte minden mező opcionális.
/** Firestore időbélyeg a válaszokban (a kérésekben ISO 8601 stringet küldesz). */export interface ApiTimestamp { _seconds: number; _nanoseconds: number;}
export const toDate = (t: ApiTimestamp): Date => new Date(t._seconds * 1000);
export interface ApiError { code: string; message: string; status: number; requestId: string; details?: Record<string, unknown>;}
export interface Pagination { hasMore: boolean; limit: number; nextCursor?: string;}
export interface Customer { id: string; name: string; phone?: string; email?: string; company?: string; taxNumber?: string; address?: { zip?: string; city?: string; street?: string; country?: string; fullText?: string }; addresses?: Array<{ id: string; label?: string; isPrimary?: boolean; zip?: string; city?: string; street?: string }>; externalIds?: Record<string, string>; customFields?: Record<string, string>; createdAt: ApiTimestamp;}
export interface WorksheetItem { id: string; type: 'material' | 'labor' | 'travel' | 'other' | 'section'; description: string; quantity: number; unit: string; /** Bruttó egységár. Kitöltetlenül a mező hiányzik. */ unitPrice?: number; totalPrice?: number; vatRate: 0 | 5 | 18 | 27; note?: string; /** Több eszközös szerviz munkalapon a `devices` tömb elemének azonosítója. */ deviceLocalId?: string; isWarranty?: boolean;}
export interface Worksheet { id: string; worksheetNumber: string; status: string; customerId: string; customerName?: string; title?: string; worksheetMode: 'field' | 'service'; isSurvey: boolean; subtotal?: number; vatAmount?: number; total?: number; items?: WorksheetItem[]; assignments?: Array<{ role: string; userId: string; userName?: string }>; /** Csak a létrehozás válaszában: jelzi, ha új ügyfél is létrejött. */ customerCreated?: boolean; createdAt: ApiTimestamp; url: string;}A status szándékosan string, nem szűk unió: a fiók egyedi státuszokat is felvehet (custom_ előtaggal), és egy szigorú unió a következő egyedi státusznál törne. A lehetséges értékek definícióit a GET /v1/worksheet-statuses adja.
A kliens
Szekció neve “A kliens”Egyetlen osztály, ami elvégzi a hitelesítést, a hibák felismerését, az idempotencia-kulcs kezelését és a 429 utáni újrapróbálást.
import { randomUUID } from 'node:crypto';
export class OkosMunkalapError extends Error { constructor( readonly code: string, readonly status: number, readonly requestId: string, message: string, readonly details?: Record<string, unknown>, ) { super(message); this.name = 'OkosMunkalapError'; }
/** Van értelme újrapróbálni ugyanazzal a kéréssel? */ get retryable(): boolean { return this.status === 429 || this.status >= 500; }}
interface RequestOptions { method?: 'GET' | 'POST' | 'PATCH' | 'DELETE'; body?: unknown; /** Létrehozó hívásokhoz. Megadás nélkül a kliens generál egyet. */ idempotencyKey?: string; query?: Record<string, string | number | undefined>;}
export class OkosMunkalapClient { constructor( private readonly apiKey: string, private readonly baseUrl = 'https://api.okosmunkalap.hu', /** Hány újrapróbálás fér bele egy hívásba (429 és 5xx esetén). */ private readonly maxRetries = 3, ) {}
/** Egyetlen erőforrás (a válasz `data` mezője). */ async request<T>(path: string, options: RequestOptions = {}): Promise<T> { const { data } = await this.send<T>(path, options); return data; }
/** Listavégpont: a `data` mellett a `pagination` is kell a lapozáshoz. */ async requestPage<T>( path: string, options: RequestOptions = {}, ): Promise<{ data: T[]; pagination: Pagination }> { const { data, pagination } = await this.send<T[]>(path, options); return { data, pagination: pagination ?? { hasMore: false, limit: 0 } }; }
private async send<T>( path: string, options: RequestOptions, ): Promise<{ data: T; pagination?: Pagination }> { const method = options.method ?? 'GET'; const url = new URL(this.baseUrl + path); for (const [key, value] of Object.entries(options.query ?? {})) { if (value !== undefined) url.searchParams.set(key, String(value)); }
const headers: Record<string, string> = { Authorization: `Bearer ${this.apiKey}` }; if (options.body !== undefined) headers['Content-Type'] = 'application/json'; // Az idempotencia-kulcs a hívás elején dől el, és az ÖSSZES újrapróbáláson ugyanaz marad. // Ez a lényege: ettől lesz a megismételt kérés ugyanaz a művelet, nem egy második. if (method === 'POST') { headers['Idempotency-Key'] = options.idempotencyKey ?? randomUUID(); } const body = options.body === undefined ? undefined : JSON.stringify(options.body);
let lastError: OkosMunkalapError | undefined; for (let attempt = 0; attempt <= this.maxRetries; attempt++) { const response = await fetch(url, { method, headers, body }); const text = await response.text(); const payload = text ? (JSON.parse(text) as Record<string, unknown>) : {};
if (response.ok) return payload as { data: T; pagination?: Pagination };
const error = payload.error as ApiError | undefined; lastError = new OkosMunkalapError( error?.code ?? 'UNKNOWN', response.status, error?.requestId ?? '', error?.message ?? `HTTP ${response.status}`, error?.details, ); if (!lastError.retryable || attempt === this.maxRetries) throw lastError;
// A Retry-After a szerver kérése, ezért az elsőbbséget élvez. Ha hiányzik (5xx), // exponenciális visszalépés jitterrel, hogy több kliens ne egyszerre próbálkozzon újra. const retryAfter = Number(response.headers.get('retry-after')); const waitMs = Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 : 2 ** attempt * 500 + Math.floor(Math.random() * 250); await new Promise((resolve) => setTimeout(resolve, waitMs)); } throw lastError; }}Két részlet, ami könnyen elromlik, ezért a kód külön kezeli:
- Az idempotencia-kulcs az újrapróbálásokon át ugyanaz marad. Ha minden próbálkozás új kulcsot kapna, egy időtúllépés utáni ismétlés második rekordot hozna létre. Éppen ez ellen véd a fejléc.
- A
Retry-Aftermegelőzi a saját visszalépést. A429válasz megmondja, hány másodperc múlva nyílik a következő ablak; ennél korábban újrapróbálni felesleges forgalom.
Ügyfél és munkalap egy lépésben
Szekció neve “Ügyfél és munkalap egy lépésben”A createIfMissing ág akkor is megtalálja a meglévő ügyfelet, ha az már létezik, tehát nem kell előtte keresned.
const client = new OkosMunkalapClient(process.env.OKOSMUNKALAP_API_KEY!);
const worksheet = await client.request<Worksheet>('/v1/worksheets', { method: 'POST', body: { customer: { lookupExternalId: 'CUST-9001', createIfMissing: { name: 'Példa Kft.', phone: '+36 30 123 4567', address: { fullText: '1051 Budapest, Fő utca 1.' }, }, }, title: 'Hibajavítás', siteContactName: 'Kovács Anna', siteContactPhone: '+36 70 222 3344', },});
console.log(worksheet.worksheetNumber, worksheet.customerCreated ? '(új ügyfél)' : '(meglévő ügyfél)');A hibákat a code mezőre ágazva kezeld, ne az üzenet szövegére. A teljes katalógus a Hibakódok oldalon van.
try { await client.request<Worksheet>('/v1/worksheets', { method: 'POST', body: payload });} catch (error) { if (!(error instanceof OkosMunkalapError)) throw error;
switch (error.code) { case 'EXTERNAL_ID_AMBIGUOUS': // A külső azonosító több élő ügyfélre illeszkedik: a rendszer szándékosan nem választ. // Jelezd a kezelőnek, hogy az azonosító nem egyértelmű, és ne hozz létre semmit. await notifyDispatcher(error.requestId); break; case 'MISSING_PERMISSION': // A kulcs jogosultságai hiányosak; ez kód-hiba, nem futásidejű állapot. throw error; default: // A requestId a kulcs a hibakereséshez, ezért mindig naplózd. logger.error({ code: error.code, requestId: error.requestId }, error.message); throw error; }}Lapozás
Szekció neve “Lapozás”A listavégpontok kurzor alapúak. A lapozás közben ne változtasd a szűrőket: a kurzor hozzájuk van kötve, és eltérés esetén 400 jön INVALID_CURSOR kóddal.
async function* iterateWorksheets( client: OkosMunkalapClient, filters: Record<string, string | number | undefined> = {},): AsyncGenerator<Worksheet> { let cursor: string | undefined; do { const page = await client.requestPage<Worksheet>('/v1/worksheets', { query: { ...filters, limit: 100, starting_after: cursor }, }); for (const worksheet of page.data) yield worksheet; cursor = page.pagination.hasMore ? page.pagination.nextCursor : undefined; } while (cursor);}
for await (const worksheet of iterateWorksheets(client, { status: 'completed' })) { console.log(worksheet.worksheetNumber, worksheet.total);}Webhook aláírás ellenőrzése
Szekció neve “Webhook aláírás ellenőrzése”A Webhookok oldalon szereplő JavaScript és PHP minta TypeScript változata, Express környezetben.
A legfontosabb: az aláírás a nyers kéréstörzsön készül. Ha a keretrendszered már objektummá alakította a JSON-t, és te azt szerializálod újra, az ellenőrzés akkor is elbukik, ha az aláírás helyes. Ezért a webhook útvonalon nyers törzset kell kérni.
import express from 'express';import crypto from 'node:crypto';
const TOLERANCE_SEC = 300; // ötperces replay-ablak
export function verifySignature( rawBody: Buffer, signatureHeader: string | undefined, secret: string, toleranceSec = TOLERANCE_SEC,): boolean { if (!signatureHeader) return false;
const parts = new Map( signatureHeader.split(',').map((part) => { const [key, ...rest] = part.trim().split('='); return [key, rest.join('=')] as const; }), ); const timestamp = Number(parts.get('t')); const received = parts.get('v1'); if (!Number.isInteger(timestamp) || !received) return false;
// Replay-ablak: a túl régi és a jövőbeli időbélyeg is elutasítandó. if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > toleranceSec) return false;
const expected = crypto .createHmac('sha256', secret) .update(`${timestamp}.${rawBody.toString('utf8')}`) .digest('hex');
const a = Buffer.from(expected, 'utf8'); const b = Buffer.from(received, 'utf8'); // A timingSafeEqual eltérő hosszra kivételt dob, ezért előbb a hosszt hasonlítjuk. return a.length === b.length && crypto.timingSafeEqual(a, b);}
const app = express();
app.post( '/webhooks/okosmunkalap', express.raw({ type: 'application/json' }), // NYERS törzs, nem express.json() (req, res) => { const valid = verifySignature( req.body as Buffer, req.header('X-OkosMunkalap-Signature'), process.env.OKOSMUNKALAP_WEBHOOK_SECRET!, ); if (!valid) return res.status(400).send('invalid signature');
const event = JSON.parse((req.body as Buffer).toString('utf8')) as { id: string; type: string; webhookVersion: string; createdAt: string; data: Worksheet | Customer; };
// Válaszolj gyorsan, és a feldolgozást tedd háttérbe: a küldő időtúllépésre újrapróbál. res.status(200).send('ok');
// Deduplikáció: ugyanaz az esemény újrapróbáláskor ugyanazt az `id`-t hozza. void handleEventOnce(event.id, event.type, event.data); },);Az eseményeket mindig deduplikáld az id mező alapján (ez az X-OkosMunkalap-Delivery fejléccel azonos). Ugyanaz az esemény hálózati hiba után újra megérkezhet, és az azonosító ilyenkor változatlan marad.
Generált kliens a specből
Szekció neve “Generált kliens a specből”Ha nem kézzel írt klienst szeretnél, az OpenAPI leírásból generálhatsz egyet:
npx @openapitools/openapi-generator-cli generate \ -i https://docs.okosmunkalap.hu/openapi.yaml \ -g typescript-fetch \ -o ./src/okosmunkalapA generált kliens a mezőneveket és a típusokat pontosan követi, viszont a fenti újrapróbálást és idempotencia-kezelést neked kell köré tenned: a generátor ezeket nem ismeri.