From 88bf5a84d89a16ea2a442fde3cc4e8e57500b647 Mon Sep 17 00:00:00 2001 From: igor Date: Tue, 26 May 2026 13:45:15 +0200 Subject: [PATCH] added into generated client response handlers --- AGENTS.md | 2 ++ README.md | 18 +++++++++++ src/javascript.tpl.php | 52 +++++++++++++++++++++++++++++-- src/typescript.tpl.php | 71 +++++++++++++++++++++++++++++++++++++++--- 4 files changed, 135 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 337c9f6..6effe84 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,6 +23,7 @@ The HTML help page is also interactive: - JavaScript stays plain JS. - TypeScript stays typed (interfaces + typed method signatures). - Bearer token behavior stays aligned between JavaScript and TypeScript clients (`bearerSet()`, `apilite_bearer_token`, automatic `Authorization: Bearer ...` header). + - Response/error handler behavior stays aligned between JavaScript and TypeScript clients (`addResponseHandler()`, `addErrorHandler()`, unsubscribe return value, `(response, context)` arguments, ignored handler exceptions). - Keep the HTML help page usable both as documentation and as a lightweight in-browser tester. - If you change output field names in JSON help, update README and templates consistently. - If you change request metadata or tester behavior, update `README.md` and keep `src/help.tpl.php` aligned with the actual runtime request model. @@ -44,3 +45,4 @@ The HTML help page is also interactive: ## Documentation rule When output format names/flags/URLs change, update `README.md` in the same change set. When Bearer token helper/storage behavior changes, update `README.md` and keep JS/TS client docs aligned with the actual generated templates. +When generated client handler behavior changes, update `README.md` and keep JS/TS client docs aligned with the actual generated templates. diff --git a/README.md b/README.md index 976745f..4420081 100644 --- a/README.md +++ b/README.md @@ -240,16 +240,29 @@ You can also download generated frontend clients: Both generated clients support a shared Bearer token helper. Call `bearerSet(token)` once, the token is stored in `localStorage` under `apilite_bearer_token`, and subsequent requests automatically send `Authorization: Bearer ...`. +Both generated clients also support response hooks: + +* `addResponseHandler(handler)` registers a handler called for every API response. +* `addErrorHandler(handler)` registers a handler called only when the API response has `status === 'ERROR'`. +* Both methods return an unsubscribe function. +* Handlers receive `(response, context)`, where `context` contains `method`, `data`, and `httpStatus`. +* Handler exceptions are ignored, so they do not change the original API callback or promise result. + JavaScript usage example: ```js import backend from './backend.js'; backend.bearerSet('your-access-token'); +const removeErrorHandler = backend.addErrorHandler((response, context) => { + console.error(context.method, response.msg); +}); backend.add(1, 2).then((response) => { console.log(response.data); }); + +// removeErrorHandler(); ``` TypeScript usage example (Vue + TS): @@ -258,10 +271,15 @@ TypeScript usage example (Vue + TS): import backend from './backend'; backend.bearerSet('your-access-token'); +const removeResponseHandler = backend.addResponseHandler((response, context) => { + console.log(context.httpStatus, response.status); +}); backend.add(1, 2).then((response) => { console.log(response.data); // typed value based on PHP return type }); + +// removeResponseHandler(); ``` If a method return type is a PHP class with public properties, JSON help now also includes `return_structure`, and the generated TypeScript client maps that class to an object shape based on those public properties. diff --git a/src/javascript.tpl.php b/src/javascript.tpl.php index 8e1a186..49d0c1a 100644 --- a/src/javascript.tpl.php +++ b/src/javascript.tpl.php @@ -8,6 +8,8 @@ class apiName; ?> { endpoint = endpoint, 0, 4) == 'http' ? '"%s"' : '%s', $this->endpoint); ?>; bearerStorageKey = 'apilite_bearer_token'; + responseHandlers = []; + errorHandlers = []; normalizeBearerToken(token) { if (typeof token !== 'string') return null; @@ -58,20 +60,64 @@ class apiName; ?> { storage.setItem(this.bearerStorageKey, token); } + addResponseHandler(handler) { + this.responseHandlers.push(handler); + return () => { + this.responseHandlers = this.responseHandlers.filter(registeredHandler => registeredHandler !== handler); + }; + } + + addErrorHandler(handler) { + this.errorHandlers.push(handler); + return () => { + this.errorHandlers = this.errorHandlers.filter(registeredHandler => registeredHandler !== handler); + }; + } + + emitResponseHandlers(response, context) { + this.responseHandlers.forEach(handler => { + try { + handler(response, context); + } catch (error) { + // Response handlers must not change the API request result. + } + }); + } + + emitErrorHandlers(response, context) { + this.errorHandlers.forEach(handler => { + try { + handler(response, context); + } catch (error) { + // Error handlers must not change the API request result. + } + }); + } + + emitHandlers(response, context) { + this.emitResponseHandlers(response, context); + if (response.status === 'ERROR') this.emitErrorHandlers(response, context); + } + /* ---------------------------------------------------- * General API call */ call(method, data, callback) { var xhttp = new XMLHttpRequest(); var headers = this.getRequestHeaders(); + var api = this; xhttp.withCredentials = true; xhttp.onreadystatechange = function() { if (this.readyState === 4) { + var response; if (this.status === 200) { - if (callback != null) callback(JSON.parse(this.responseText)); + response = JSON.parse(this.responseText); } else { - if (callback != null) callback({'status': 'ERROR', 'msg': 'HTTP STATUS ' + this.status}); + response = {'status': 'ERROR', 'msg': 'HTTP STATUS ' + this.status}; } + var context = {method: method, data: data, httpStatus: this.status}; + api.emitHandlers(response, context); + if (callback != null) callback(response); } } var form_data = new FormData(); @@ -118,4 +164,4 @@ class apiName; ?> { }; -export default new apiName; ?>(); \ No newline at end of file +export default new apiName; ?>(); diff --git a/src/typescript.tpl.php b/src/typescript.tpl.php index 638840e..75c8468 100644 --- a/src/typescript.tpl.php +++ b/src/typescript.tpl.php @@ -152,11 +152,23 @@ export interface APIliteHelpResponse { msg: string; } +export type APIliteResponse = APIliteHelpResponse | APIliteActionResponse | APIliteErrorResponse; + +export interface APIliteResponseContext { + method: string; + data: Record; + httpStatus: number; +} + type APIliteRequestHeaders = Partial>; +type APIliteResponseHandler = (response: APIliteResponse, context: APIliteResponseContext) => void; +type APIliteErrorHandler = (response: APIliteErrorResponse, context: APIliteResponseContext) => void; class apiName; ?> { endpoint: string = endpoint, 0, 4) == 'http' ? '"%s"' : '%s', $this->endpoint); ?>; private readonly bearerStorageKey: string = 'apilite_bearer_token'; + private responseHandlers: APIliteResponseHandler[] = []; + private errorHandlers: APIliteErrorHandler[] = []; private normalizeBearerToken(token: string | null | undefined): string | null { if (typeof token !== 'string') { @@ -219,22 +231,71 @@ class apiName; ?> { storage.setItem(this.bearerStorageKey, normalizedToken); } + addResponseHandler(handler: APIliteResponseHandler): () => void { + this.responseHandlers.push(handler); + return () => { + this.responseHandlers = this.responseHandlers.filter((registeredHandler) => registeredHandler !== handler); + }; + } + + addErrorHandler(handler: APIliteErrorHandler): () => void { + this.errorHandlers.push(handler); + return () => { + this.errorHandlers = this.errorHandlers.filter((registeredHandler) => registeredHandler !== handler); + }; + } + + private emitResponseHandlers(response: APIliteResponse, context: APIliteResponseContext): void { + this.responseHandlers.forEach((handler) => { + try { + handler(response, context); + } catch { + // Response handlers must not change the API request result. + } + }); + } + + private emitErrorHandlers(response: APIliteErrorResponse, context: APIliteResponseContext): void { + this.errorHandlers.forEach((handler) => { + try { + handler(response, context); + } catch { + // Error handlers must not change the API request result. + } + }); + } + + private isErrorResponse(response: APIliteResponse): response is APIliteErrorResponse { + return response.status === 'ERROR'; + } + + private emitHandlers(response: APIliteResponse, context: APIliteResponseContext): void { + this.emitResponseHandlers(response, context); + if (this.isErrorResponse(response)) { + this.emitErrorHandlers(response, context); + } + } + private call( method: string, data: Record, - callback: (response: APIliteHelpResponse | APIliteActionResponse | APIliteErrorResponse) => void + callback: (response: APIliteResponse) => void ): void { const xhttp = new XMLHttpRequest(); const headers = this.getRequestHeaders(); + const api = this; xhttp.withCredentials = true; xhttp.onreadystatechange = function() { if (this.readyState === 4) { + let response: APIliteResponse; if (this.status === 200) { - const response = JSON.parse(this.responseText) as APIliteHelpResponse | APIliteActionResponse | APIliteErrorResponse; - callback(response); + response = JSON.parse(this.responseText) as APIliteResponse; } else { - callback({ status: 'ERROR', msg: 'HTTP STATUS ' + this.status }); + response = { status: 'ERROR', msg: 'HTTP STATUS ' + this.status }; } + const context: APIliteResponseContext = { method, data, httpStatus: this.status }; + api.emitHandlers(response, context); + callback(response); } }; @@ -297,4 +358,4 @@ class apiName; ?> { } -export default new apiName; ?>(); \ No newline at end of file +export default new apiName; ?>();