From 82a2b8193479efa95a61c81945e410d65e49507f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 03:42:02 +0000 Subject: [PATCH] Update README for v1.1.0 API surface and PHP client Rewrite the README so it matches the shipped code: - Drop the GET-only / no-auth claim; document ApiClient post, put and delete plus the BlogClient createComment and Admin API methods added in v1.1.0, with translation-map examples from BLOG_V2_API.md. - Remove getCommentById from the JS BlogClient reference (it no longer exists) and drop the unused status option on getCommentsByPostId. - Correct the localization utility import path; the helpers are not re-exported from the package root. Note the az/en/ru suffix limit of localizeObject. - Fix the broken getRecords response-format snippet. - Add Authentication, Request Caching, Development and Related Documentation sections; note that Node < 18 needs a fetch polyfill. - Consolidate the PHP material into one section, fix the Composer VCS URL to the KeyTechAndDesign org, and record where the PHP client diverges from the JS client (V1 comment routes, no write support). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01HxE5N6Z3mmNwv8vdAMzbfE --- README.md | 874 ++++++++++++++++++++++++++++-------------------------- 1 file changed, 452 insertions(+), 422 deletions(-) diff --git a/README.md b/README.md index 5722cb2..62a0ded 100644 --- a/README.md +++ b/README.md @@ -3,10 +3,11 @@ [![npm version](https://img.shields.io/npm/v/@keytd/dynamic-backend-client.svg)](https://www.npmjs.com/package/@keytd/dynamic-backend-client) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Node.js Version](https://img.shields.io/node/v/@keytd/dynamic-backend-client.svg)](https://nodejs.org) +[![CI](https://github.com/KeyTechAndDesign/dynamic-backend-client/actions/workflows/ci.yml/badge.svg)](https://github.com/KeyTechAndDesign/dynamic-backend-client/actions/workflows/ci.yml) -A JavaScript client library for interacting with the Dynamic Backend API. This library provides a clean, type-documented interface for working with blog content and dynamic tables. +A JavaScript client library for the Dynamic Backend API, with a companion read-only PHP client in the same repository. It provides a clean, type-documented interface for blog content (public and admin), dynamic tables, and client-side localization. -> **Note:** This library only supports GET requests and does not require authorization. It is designed for read-only operations with public APIs. +> **Note:** The public blog and table endpoints are read-only and need no authorization. Since v1.1.0 the JavaScript client also supports write operations (`POST`, `PUT`, `DELETE`) and the Blog Admin API. Admin endpoints require authentication, which you supply through request headers (see [Authentication](#-authentication)). ## 📋 Table of Contents @@ -16,26 +17,34 @@ A JavaScript client library for interacting with the Dynamic Backend API. This l - [📚 API Reference](#-api-reference) - [ApiClient](#apiclient) - [BlogClient](#blogclient) + - [Public API](#public-api) + - [Admin API](#admin-api) - [TableClient](#tableclient) - [Localization Utility](#localization-utility) +- [💾 Request Caching](#-request-caching) +- [🔐 Authentication](#-authentication) - [❌ Error Handling](#-error-handling) - [🌐 Multilingual Support](#-multilingual-support) +- [🐘 PHP Client](#-php-client) - [🔄 Compatibility](#-compatibility) +- [🧪 Development](#-development) +- [📖 Related Documentation](#-related-documentation) - [👥 Contributing](#-contributing) - [📄 License](#-license) ## ✨ Features - 🔄 **Framework Agnostic** - Works with any JavaScript environment that supports the Fetch API -- 📊 **Dynamic Tables** - Access and query dynamic database tables -- 📝 **Blog Content** - Retrieve blog posts, categories, tags, and comments -- 🌐 **Multilingual Support** - Built-in support for multilingual content -- 🔍 **Filtering & Pagination** - Filter records and paginate results -- 🛡️ **Type Definitions** - Complete TypeScript definitions for better development experience -- 🚀 **Lightweight** - Minimal dependencies for faster loading -- 🧩 **Modular Design** - Use only the components you need -- 📦 **Optional Request Caching** - Built-in caching mechanism that can be enabled for improved performance and reduced API calls -- 🌍 **Client-side Localization** - Utilities for handling localized fields in data objects +- 📊 **Dynamic Tables** - List tables, inspect their structure, and query records with filtering and pagination +- 📝 **Blog Content** - Retrieve posts, categories, tags, and comments, and submit comments +- 🛠️ **Blog Admin API** - Create, update, publish, archive, and delete posts, categories, tags, and comments (Blog System V2) +- 🌐 **Multilingual Support** - `lang` parameter on public endpoints and translation maps on admin endpoints +- 🌍 **Client-side Localization** - Utilities that collapse `name_en` / `nameAz` style fields into a single localized value +- 🏢 **Multi-tenant** - Target a tenant schema through the `X-Schema` header +- 📦 **Optional Request Caching** - In-memory cache for `GET` requests with configurable expiry +- 🛡️ **Type Definitions** - TypeScript definitions shipped in `types/index.d.ts` +- 🚀 **Lightweight** - Zero runtime dependencies +- 🐘 **PHP Client** - Read-only PHP 8 implementation with the same surface for blog, tables, and localization ## 📦 Installation @@ -50,119 +59,7 @@ yarn add @keytd/dynamic-backend-client pnpm add @keytd/dynamic-backend-client ``` -### PHP (Composer) Installation - -This repository now includes a lightweight PHP implementation of the Dynamic Backend Client for read-only GET access. - -You can add it to your project in several ways depending on your workflow: - -1) Packagist (planned) -- If/when the PHP package is published to Packagist, you will be able to require it normally: - composer require keytd/dynamic-backend-client-php - -2) VCS repository (use directly from GitHub) -- Add this repository as a Composer VCS repository and require the PHP package name from composer.json in this repo: - -```json -{ - "repositories": [ - { "type": "vcs", "url": "https://github.com/keytd/dynamic-backend-client" } - ], - "require": { - "php": ">=8.0", - "keytd/dynamic-backend-client-php": "dev-main" - } -} -``` - -Then run: - -```bash -composer update keytd/dynamic-backend-client-php -``` - -3) Local path repository (use a local clone) -- If you have the repository checked out locally, you can reference it via a path repository: - -```json -{ - "repositories": [ - { "type": "path", "url": "../dynamic-backend-client" } - ], - "require": { - "php": ">=8.0", - "keytd/dynamic-backend-client-php": "dev-main" - } -} -``` - -Then run: - -```bash -composer update keytd/dynamic-backend-client-php -``` - -4) Copy-only (no VCS) -- Copy the php/ directory into your project and set up PSR-4 autoloading to that folder: - -```json -{ - "name": "your-vendor/your-project", - "require": { - "php": ">=8.0" - }, - "autoload": { - "psr-4": { - "KeyTD\\DynamicBackendClient\\": "php/" - } - } -} -``` - -Then run: - -```bash -composer dump-autoload -``` - -### PHP Client Status - -- Scope: Read-only, GET-only client that mirrors the JS client's surface for Blog and Tables plus localization utilities. -- Implemented: ApiClient (cURL-based HTTP, timeout, X-Schema header, optional in-memory caching), BlogClient (posts, categories, tags, comments), TableClient (list/info/records/record by id with client-side localization), LocalizeUtil. -- Tested: PHPUnit test suite included (no network calls; uses stubs). Run with composer test. -- Requirements: PHP 8.0+, ext-curl enabled. -- Not included: POST/PUT/PATCH/DELETE, authentication flows. - -### Publishing the PHP package to Packagist - -You do not need a separate repository for the PHP client. Packagist can consume this same repository (monorepo) as long as a valid composer.json is present at the repository root — which it is. We’ve also added a .gitattributes file to ensure Composer dist archives include only the necessary PHP files. - -Recommended steps: - -1) Prepare the repository -- Ensure composer.json is correct (package name, description, license, autoload). This repo already uses: keytd/dynamic-backend-client-php and PSR-4 autoload for KeyTD\\DynamicBackendClient\\ → php/. -- Optional but recommended: keep .gitattributes in place to export-ignore non-PHP assets (JS, tests, etc.). - -2) Tag a release -- Commit any pending changes. -- Create a semantic version tag, e.g. v0.1.0: - git tag v0.1.0 - git push origin v0.1.0 - -3) Submit to Packagist -- Log in to https://packagist.org and click “Submit”. -- Paste the GitHub repository URL (this repo) and submit. -- Packagist will detect the package from the root composer.json and list versions based on your Git tags. - -4) Enable auto-updates -- On the Packagist package page, enable GitHub auto-updates (if prompted) or add the Packagist GitHub hook from the Settings → Webhooks of your GitHub repo. This keeps Packagist in sync when you push new tags. - -5) Usage after publish -- Consumers can install via Composer: - composer require keytd/dynamic-backend-client-php - -When would a separate repo be useful? -- If you want a lighter OSS footprint, isolated issue tracking, or different release cadence for PHP vs JS. In that case, you can move the php/ folder and composer.json into a new repository, keep the same package name (or choose a new one), and submit that repo to Packagist. This is optional — the monorepo approach works fine and is common. +For the PHP client, see [PHP Client](#-php-client). ## 🚀 Quick Start @@ -174,8 +71,8 @@ import { ApiClient, BlogClient, TableClient } from '@keytd/dynamic-backend-clien // Create the base API client const apiClient = new ApiClient({ baseUrl: 'https://your-api-url.com', - schema: 'your_schema', - timeout: 30000 // Optional, default is 30000ms + schema: 'your_schema', // Optional, default is 'public' + timeout: 30000 // Optional, default is 30000ms }); // Create specialized clients @@ -186,7 +83,7 @@ const tableClient = new TableClient(apiClient); ### Working with Blog Content ```javascript -// Fetch blog posts with pagination and filtering +// Fetch published posts with pagination and filtering async function fetchBlogPosts() { try { const posts = await blogClient.getPosts({ @@ -202,19 +99,16 @@ async function fetchBlogPosts() { return posts; } catch (error) { console.error('Error fetching posts:', error.message); - // Handle error appropriately } } // Get a specific blog post by slug async function getPostBySlug(slug) { try { - const post = await blogClient.getPostBySlug(slug, { + return await blogClient.getPostBySlug(slug, { include_tags: true, include_comments: true }); - - return post; } catch (error) { if (error.status === 404) { console.error('Post not found'); @@ -223,102 +117,18 @@ async function getPostBySlug(slug) { } } } -``` - -### PHP Quick Start - -```php - 'https://your-api-url.com', - 'schema' => 'your_schema', - 'timeout' => 30000, // milliseconds - 'enableCache' => true, -]); - -$blog = new BlogClient($apiClient); -$table = new TableClient($apiClient); - -// Fetch posts -$posts = $blog->getPosts([ - 'page' => 1, - 'page_size' => 10, - 'status' => 'published', - 'include_tags' => true, - 'lang' => 'en' -]); - -// Fetch records from a table with localization -$records = $table->getRecords('products', [ - 'page' => 1, - 'pageSize' => 10, - 'filter' => ['category' => 'electronics'], - 'locale' => 'en', - 'defaultLocale' => 'en' -]); - -// Fetch comments for a post (mirrors JS client parameters) -$comments = $blog->getCommentsByPostId(123, [ - 'status' => 'approved', // default - 'lang' => 'en', -]); - -// Fetch a single comment by ID -$comment = $blog->getCommentById(456, [ - 'lang' => 'en', -]); -``` - -### Troubleshooting (PHP) - -- Class not found: Ensure Composer autoload is configured and vendor/autoload.php is required. If using the copy-only approach, confirm the PSR-4 mapping points to the php/ directory and run composer dump-autoload. -- cURL errors: The PHP extension ext-curl must be enabled. On Linux, install/enable php-curl. On Windows, enable the curl extension in php.ini and restart your web server/CLI session. -- PHP version: Requires PHP >= 8.0. -- Timeouts: The timeout is in milliseconds. Adjust when constructing ApiClient: new ApiClient(['timeout' => 30000]). -- Caching: To enable in-process request caching, pass enableCache => true (and optionally cacheMaxAge in ms) to ApiClient. -### Working with Request Caching - -```javascript -// Configure caching when creating the API client (caching is disabled by default) -const apiClient = new ApiClient({ - baseUrl: 'https://your-api-url.com', - schema: 'your_schema', - enableCache: true, // Enable caching (default: false) - cacheMaxAge: 5 * 60 * 1000 // Cache expiry time in ms (default: 5 minutes) -}); - -// Make a request that will be cached (since we enabled caching) -async function fetchDataWithCache() { - try { - // This response will be cached (because we enabled caching) - const firstResponse = await apiClient.get('/endpoint', { param: 'value' }); - console.log('First request:', firstResponse); - - // This will use the cached response if within cacheMaxAge - const secondResponse = await apiClient.get('/endpoint', { param: 'value' }); - console.log('Second request (from cache):', secondResponse); - - // Skip cache for this specific request - const freshResponse = await apiClient.get('/endpoint', { param: 'value' }, {}, { skipCache: true }); - console.log('Fresh request (bypassing cache):', freshResponse); - - // Clear cache for a specific endpoint - apiClient.clearCache('/endpoint'); - - // Clear the entire cache - apiClient.clearCache(); - } catch (error) { - console.error('Error:', error.message); - } +// Submit a visitor comment on a post +async function leaveComment(postId) { + return blogClient.createComment(postId, { + author_name: 'Jane Doe', + author_email: 'jane@example.com', + content: 'Great article!', + parent_id: 42 // Optional: reply to an existing comment + }); } ``` + ### Working with Dynamic Tables ```javascript @@ -340,49 +150,81 @@ async function fetchTableRecords(tableName) { return records; } catch (error) { console.error(`Error fetching records from ${tableName}:`, error.message); - // Handle error appropriately } } // Get table structure async function getTableStructure(tableName) { - try { - const tableInfo = await tableClient.getTableInfo(tableName); - console.log('Table columns:', tableInfo.columns); - return tableInfo; - } catch (error) { - console.error(`Error fetching table info: ${error.message}`); - } + const tableInfo = await tableClient.getTableInfo(tableName); + console.log('Table columns:', tableInfo.columns); + return tableInfo; } ``` +### Managing Content with the Admin API + +```javascript +// Pass an auth token to the ApiClient by supplying headers on each call, +// or wrap the client (see the Authentication section below). +const authHeaders = { Authorization: `Bearer ${token}` }; + +// Create a draft post with translations, then publish it +const draft = await apiClient.post('/api/admin/blog/posts', { + category_id: 1, + author_id: 1, + status: 'draft', + tag_ids: [1, 2], + translations: [ + { locale: 'en', title: 'Hello', content: 'World' }, + { locale: 'az', title: 'Salam', content: 'Dünya' } + ] +}, authHeaders); + +await apiClient.post(`/api/admin/blog/posts/${draft.id}/publish`, {}, authHeaders); +``` + +The same operations are available as `BlogClient` convenience methods (`adminCreatePost`, `adminPublishPost`, and so on). See [Admin API](#admin-api) for the full list. + +More complete, runnable examples live in the [`examples/`](examples/README.md) directory. + ## 📚 API Reference ### ApiClient -The base client that handles error handling and HTTP requests. +The base client that handles HTTP requests, timeouts, caching, and error handling. Every other client takes an `ApiClient` instance in its constructor. ```javascript const apiClient = new ApiClient({ baseUrl: 'https://api.example.com', // Required: Base URL for the API schema: 'public', // Optional: Schema for multi-tenant support (default: 'public') timeout: 30000, // Optional: Request timeout in milliseconds (default: 30000) - enableCache: true, // Optional: Enable response caching (default: false) + enableCache: true, // Optional: Enable GET response caching (default: false) cacheMaxAge: 300000 // Optional: Maximum age of cached responses in ms (default: 300000 - 5 minutes) }); ``` +Every request is sent with the headers `Content-Type: application/json`, `Accept: application/json`, and `X-Schema: `. Any `headers` argument you pass is merged on top of these. + #### Methods | Method | Description | Parameters | Returns | |--------|-------------|------------|---------| | `setSchema(schema)` | Set the schema for multi-tenant support | `schema` (string): Schema name | `void` | -| `clearCache(endpoint)` | Clear the request cache | `endpoint` (string, optional): Specific endpoint to clear (clears all if not specified) | `void` | -| `get(endpoint, params, headers, options)` | Make a GET request | `endpoint` (string): API endpoint
`params` (object, optional): Query parameters
`headers` (object, optional): Additional headers
`options` (object, optional):
- `skipCache` (boolean): Skip cache for this request | `Promise`: Response data | +| `clearCache(endpoint)` | Clear the request cache | `endpoint` (string, optional): Clear only entries whose URL starts with this path (clears all if not specified) | `void` | +| `get(endpoint, params, headers, options)` | Make a GET request | `endpoint` (string): API endpoint
`params` (object, optional): Query parameters (`undefined` and `null` values are skipped)
`headers` (object, optional): Additional headers
`options` (object, optional):
- `skipCache` (boolean): Bypass the cache for this request | `Promise`: Response data | +| `post(endpoint, data, headers)` | Make a POST request | `endpoint` (string): API endpoint
`data` (object, optional): JSON request body
`headers` (object, optional): Additional headers | `Promise`: Response data | +| `put(endpoint, data, headers)` | Make a PUT request | `endpoint` (string): API endpoint
`data` (object, optional): JSON request body
`headers` (object, optional): Additional headers | `Promise`: Response data | +| `delete(endpoint, headers)` | Make a DELETE request | `endpoint` (string): API endpoint
`headers` (object, optional): Additional headers | `Promise`: Response data | + +Notes: + +- Only `get` uses the cache. Write requests always hit the network. +- A `204 No Content` response resolves to `null`. +- The `endpoint` is appended to `baseUrl` as-is, so include the leading slash (for example `/api/blog/posts`). ### BlogClient -Client for working with blog-related content such as posts, categories, tags, and comments. +Client for the Blog System V2. It exposes the public, read-mostly endpoints under `/api/blog` and the authenticated admin endpoints under `/api/admin/blog`. See [BLOG_V2_API.md](BLOG_V2_API.md) for the server-side contract. ```javascript // Basic initialization @@ -394,7 +236,11 @@ const blogClient = new BlogClient(apiClient, { }); ``` -#### Categories Methods +#### Public API + +Public endpoints return content in a single locale, chosen by the `lang` option. Posts and comments are flat objects for that locale. + +##### Categories | Method | Description | Parameters | Returns | |--------|-------------|------------|---------| @@ -402,7 +248,7 @@ const blogClient = new BlogClient(apiClient, { | `getCategoryById(id, options)` | Get a category by ID | `id` (number): Category ID
`options` (object, optional):
- `lang` (string): Language code | `Promise`: Category data | | `getCategoryBySlug(slug, options)` | Get a category by slug | `slug` (string): Category slug
`options` (object, optional):
- `lang` (string): Language code | `Promise`: Category data | -#### Tags Methods +##### Tags | Method | Description | Parameters | Returns | |--------|-------------|------------|---------| @@ -410,24 +256,74 @@ const blogClient = new BlogClient(apiClient, { | `getTagById(id, options)` | Get a tag by ID | `id` (number): Tag ID
`options` (object, optional):
- `lang` (string): Language code | `Promise`: Tag data | | `getTagBySlug(slug, options)` | Get a tag by slug | `slug` (string): Tag slug
`options` (object, optional):
- `lang` (string): Language code | `Promise`: Tag data | -#### Posts Methods +##### Posts | Method | Description | Parameters | Returns | |--------|-------------|------------|---------| -| `getPosts(options)` | Get all blog posts with pagination and filtering | `options` (object, optional):
- `page` (number): Page number
- `page_size` (number): Page size
- `category_id` (number): Filter by category
- `tag_id` (number): Filter by tag
- `status` (string): Filter by status
- `include_tags` (boolean): Include tags
- `lang` (string): Language code | `Promise`: Posts with pagination | +| `getPosts(options)` | Get blog posts with pagination and filtering | `options` (object, optional):
- `page` (number, default: 1): Page number
- `page_size` (number, default: 10): Page size
- `category_id` (number): Filter by category
- `tag_id` (number): Filter by tag
- `status` (string): Filter by status (`published`, `draft`, `archived`)
- `include_tags` (boolean): Include tags
- `lang` (string): Language code | `Promise`: Posts with pagination | | `getPostById(id, options)` | Get a post by ID | `id` (number): Post ID
`options` (object, optional):
- `include_tags` (boolean): Include tags
- `include_comments` (boolean): Include comments
- `lang` (string): Language code | `Promise`: Post data | -| `getPostBySlug(slug, options)` | Get a post by slug | `slug` (string): Post slug
`options` (object, optional):
- `include_tags` (boolean): Include tags
- `include_comments` (boolean): Include comments
- `lang` (string): Language code | `Promise`: Post data | +| `getPostBySlug(slug, options)` | Get a post by its translated slug | `slug` (string): Post slug
`options` (object, optional):
- `include_tags` (boolean): Include tags
- `include_comments` (boolean): Include comments
- `lang` (string): Language code | `Promise`: Post data | + +##### Comments + +| Method | Description | Parameters | Returns | +|--------|-------------|------------|---------| +| `getCommentsByPostId(postId, options)` | Get approved comments for a post (nested structure) | `postId` (number): Post ID
`options` (object, optional):
- `lang` (string): Language code | `Promise`: List of comments | +| `createComment(postId, data)` | Submit a new comment on a post | `postId` (number): Post ID
`data` (object):
- `author_name` (string): Author name
- `author_email` (string): Author email
- `content` (string): Comment content
- `parent_id` (number, optional): Parent comment ID for a threaded reply | `Promise`: Created comment | + +> Fetching a single comment by ID is an admin-only operation in V2; use `adminGetCommentById`. + +#### Admin API + +Admin endpoints require authentication (see [Authentication](#-authentication)). They return and accept content in **all** locales at once, using a translation map keyed by locale rather than a `lang` parameter: -#### Comments Methods +```json +{ + "id": 1, + "translations": { + "en": { "title": "Welcome", "slug": "welcome", "content": "..." }, + "az": { "title": "Xoş gəlmisiniz", "slug": "xos-gelmisiniz", "content": "..." } + } +} +``` + +##### Posts | Method | Description | Parameters | Returns | |--------|-------------|------------|---------| -| `getCommentsByPostId(postId, options)` | Get comments for a post | `postId` (number): Post ID
`options` (object, optional):
- `status` (string): Filter by status
- `lang` (string): Language code | `Promise`: List of comments | -| `getCommentById(id, options)` | Get a comment by ID | `id` (number): Comment ID
`options` (object, optional):
- `lang` (string): Language code | `Promise`: Comment data | +| `adminGetPosts(options)` | List posts of every status | `options` (object, optional):
- `page` (number, default: 1)
- `page_size` (number, default: 10) | `Promise`: Posts with pagination | +| `adminGetPostById(id)` | Get a post with all its translations | `id` (number): Post ID | `Promise`: Post data | +| `adminCreatePost(data)` | Create a post | `data` (object): `category_id`, `author_id`, `status`, `tag_ids`, `translations` | `Promise`: Created post | +| `adminUpdatePost(id, data)` | Update a post and its translations | `id` (number): Post ID
`data` (object): Fields to update | `Promise`: Updated post | +| `adminDeletePost(id)` | Soft-delete a post | `id` (number): Post ID | `Promise` | +| `adminPublishPost(id)` | Set status to `published` and stamp `published_at` | `id` (number): Post ID | `Promise`: Updated post | +| `adminArchivePost(id)` | Set status to `archived` | `id` (number): Post ID | `Promise`: Updated post | + +##### Categories and Tags + +| Method | Description | Parameters | Returns | +|--------|-------------|------------|---------| +| `adminGetCategories()` | List all categories | None | `Promise` | +| `adminCreateCategory(data)` | Create a category with translations | `data` (object) | `Promise` | +| `adminUpdateCategory(id, data)` | Update a category | `id` (number), `data` (object) | `Promise` | +| `adminDeleteCategory(id)` | Hard-delete a category | `id` (number) | `Promise` | +| `adminGetTags()` | List all tags | None | `Promise` | +| `adminCreateTag(data)` | Create a tag with translations | `data` (object) | `Promise` | +| `adminUpdateTag(id, data)` | Update a tag | `id` (number), `data` (object) | `Promise` | +| `adminDeleteTag(id)` | Hard-delete a tag | `id` (number) | `Promise` | + +##### Comments + +| Method | Description | Parameters | Returns | +|--------|-------------|------------|---------| +| `adminGetCommentsByPostId(postId)` | List every comment on a post, including pending and rejected | `postId` (number) | `Promise` | +| `adminGetCommentById(id)` | Get a single comment | `id` (number) | `Promise` | +| `adminUpdateComment(id, data)` | Update a comment's status (`approved`, `rejected`) or content | `id` (number), `data` (object) | `Promise` | +| `adminDeleteComment(id)` | Soft-delete a comment | `id` (number) | `Promise` | ### TableClient -Client for working with dynamic tables. The TableClient provides a simple interface for retrieving data from dynamic database tables in your backend. +Client for working with dynamic tables. It reads table metadata and records from your backend and can localize the results on the client. ```javascript // Basic initialization @@ -445,28 +341,89 @@ const tableClient = new TableClient(apiClient, { |--------|-------------|------------|---------| | `getTables()` | Get a list of all tables in the current schema | None | `Promise`: Array of table names | | `getTableInfo(tableName)` | Get metadata about a specific table | `tableName` (string): Name of the table | `Promise`: Table metadata including columns, types, and constraints | -| `getRecords(tableName, options)` | Get records from a table with pagination and filtering | `tableName` (string): Name of the table
`options` (object, optional):
- `page` (number, default: 1): Page number
- `pageSize` (number, default: 10): Number of records per page
- `filter` (object): Key-value pairs for filtering records
- `locale` (string): Locale for localizing the returned data (e.g., 'en', 'az', 'ru')
- `defaultLocale` (string, default: 'en'): Default locale to fall back to if the requested locale is not available | `Promise`: Records with pagination information | -| `getRecordById(tableName, id, options)` | Get a single record by ID | `tableName` (string): Name of the table
`id` (number\|string): ID of the record
`options` (object, optional):
- `locale` (string): Locale for localizing the returned data (e.g., 'en', 'az', 'ru')
- `defaultLocale` (string, default: 'en'): Default locale to fall back to if the requested locale is not available | `Promise`: Record data | +| `getRecords(tableName, options)` | Get records from a table with pagination and filtering | `tableName` (string): Name of the table
`options` (object, optional):
- `page` (number, default: 1): Page number
- `pageSize` (number, default: 10): Number of records per page
- `filter` (object): Key-value pairs sent as query parameters (`page` and `pageSize` keys are ignored)
- `locale` (string): Localize each returned record (e.g., `'en'`, `'az'`, `'ru'`)
- `defaultLocale` (string, default: `'en'`): Fallback locale when the requested one is missing | `Promise`: Records with pagination information | +| `getRecordById(tableName, id, options)` | Get a single record by ID | `tableName` (string): Name of the table
`id` (number\|string): ID of the record
`options` (object, optional):
- `locale` (string): Localize the returned record
- `defaultLocale` (string, default: `'en'`): Fallback locale | `Promise`: Record data | + +#### Example + +```javascript +// Get list of available tables +const tables = await tableClient.getTables(); +console.log('Available tables:', tables); // ['users', 'products', 'orders', ...] + +// Get table structure +const tableInfo = await tableClient.getTableInfo('products'); +// { +// name: 'products', +// columns: [ +// { name: 'id', type: 'integer', primary: true }, +// { name: 'name', type: 'string', nullable: false }, +// ... +// ] +// } + +// Get filtered records with pagination +const records = await tableClient.getRecords('products', { + page: 1, + pageSize: 20, + filter: { category: 'electronics', status: 'active' } +}); + +// Get a single record, localized to Russian with English fallback +const product = await tableClient.getRecordById('products', 123, { + locale: 'ru', + defaultLocale: 'en' +}); +``` + +#### Response Format + +`getRecords` resolves to: + +```javascript +{ + data: [ + { id: 1, name: 'Product 1', price: 29.99 /* ... */ }, + { id: 2, name: 'Product 2', price: 49.99 /* ... */ } + ], + pagination: { + total: 157, // Total number of records + page: 1, // Current page + pageSize: 20, // Records per page + totalPages: 8 // Total number of pages + } +} +``` ### Localization Utility -Utility functions for handling localized fields in data objects. These utilities help you work with multilingual content when the backend returns fields with locale suffixes. +Helpers for data whose translations are stored as suffixed fields on one object (for example `name_en`, `name_az`, `nameRu`). `TableClient` applies them automatically when you pass a `locale`; you can also call them directly. + +The helpers are a CommonJS module and are not re-exported from the package root: ```javascript -import { getLocalizedField, localizeObject } from '@keytd/dynamic-backend-client'; +const { getLocalizedField, localizeObject } = require('@keytd/dynamic-backend-client/lib/localizeUtil'); ``` #### Methods | Method | Description | Parameters | Returns | |--------|-------------|------------|---------| -| `getLocalizedField(obj, baseFieldName, locale, defaultLocale)` | Extracts the localized value from an object based on the provided locale | `obj` (object): The object containing localized fields
`baseFieldName` (string): The base name of the field without locale suffix
`locale` (string): The current locale (e.g., 'az', 'en', 'ru')
`defaultLocale` (string, optional): The fallback locale if the requested locale is not available (default: 'en') | The localized value or undefined if not found | -| `localizeObject(obj, locale, defaultLocale)` | Processes an object to replace all fields that have localized versions with their localized values | `obj` (object): The object containing localized fields
`locale` (string): The current locale (e.g., 'az', 'en', 'ru')
`defaultLocale` (string, optional): The fallback locale if the requested locale is not available (default: 'en') | A new object with localized fields | +| `getLocalizedField(obj, baseFieldName, locale, defaultLocale)` | Read one field in the requested locale, falling back to `defaultLocale` and then to the un-suffixed base field | `obj` (object): The object containing localized fields
`baseFieldName` (string): Field name without the locale suffix
`locale` (string): Requested locale
`defaultLocale` (string, default: `'en'`): Fallback locale | The localized value, or `undefined` | +| `localizeObject(obj, locale, defaultLocale)` | Return a new object where every localized field group is collapsed into a single base field | `obj` (object): The object containing localized fields
`locale` (string): Requested locale
`defaultLocale` (string, default: `'en'`): Fallback locale | A new object with localized fields | + +#### Supported Field Naming Patterns + +Two naming conventions are recognized, and both are checked: + +1. **CamelCase suffix**: `fieldNameLocale` (e.g., `nameEn`, `descriptionAz`) +2. **Underscore suffix**: `field_name_locale` (e.g., `name_en`, `description_az`) -#### Example Usage +`getLocalizedField` works with any locale code. `localizeObject` currently detects only the `az`, `en`, and `ru` suffixes when deciding which keys are translations; fields with other suffixes are passed through unchanged. + +#### Example ```javascript -// Example data with localized fields const product = { id: 1, nameEn: 'Laptop', @@ -474,183 +431,110 @@ const product = { nameRu: 'Ноутбук', description_en: 'Powerful laptop for professionals', description_az: 'Peşəkarlar üçün güclü noutbuk', - description_ru: 'Мощный ноутбук для профессионалов', - price: 999.99, - inStock: true + price: 999.99 }; -// Get a specific localized field -const productName = getLocalizedField(product, 'name', 'en'); // Returns 'Laptop' -const productNameAz = getLocalizedField(product, 'name', 'az'); // Returns 'Noutbuk' -const productDesc = getLocalizedField(product, 'description', 'ru'); // Returns 'Мощный ноутбук для профессионалов' - -// Fallback to default locale if requested locale is not available -const productNameFr = getLocalizedField(product, 'name', 'fr', 'en'); // Returns 'Laptop' (fallback to English) +getLocalizedField(product, 'name', 'az'); // 'Noutbuk' +getLocalizedField(product, 'description', 'ru'); // falls back to 'en': 'Powerful laptop for professionals' -// Localize the entire object -const localizedProduct = localizeObject(product, 'az'); -console.log(localizedProduct); -// Output: +localizeObject(product, 'az'); // { // id: 1, // name: 'Noutbuk', // description: 'Peşəkarlar üçün güclü noutbuk', -// price: 999.99, -// inStock: true +// price: 999.99 // } - -// Working with API responses -async function getLocalizedProducts(locale = 'en') { - try { - // Fetch products from API - const response = await apiClient.get('/products'); - - // Localize each product in the response - const localizedProducts = response.data.map(product => - localizeObject(product, locale) - ); - - return localizedProducts; - } catch (error) { - console.error('Error fetching products:', error.message); - } -} ``` -#### Supported Field Naming Patterns - -The localization utility supports two common naming patterns for localized fields: - -1. **CamelCase format**: `fieldNameLocale` (e.g., `nameEn`, `descriptionAz`) -2. **Underscore format**: `field_name_locale` (e.g., `name_en`, `description_az`) +## 💾 Request Caching -The utility will check both formats when looking for localized fields. - -#### Working with Dynamic Tables +Caching is off by default. When enabled, `GET` responses are stored in memory, keyed by URL and headers, and reused until `cacheMaxAge` elapses. ```javascript -// Get list of available tables -const tables = await tableClient.getTables(); -console.log('Available tables:', tables); // ['users', 'products', 'orders', ...] - -// Get table structure -const tableInfo = await tableClient.getTableInfo('products'); -console.log('Table structure:', tableInfo); -// { -// name: 'products', -// columns: [ -// { name: 'id', type: 'integer', primary: true }, -// { name: 'name', type: 'string', nullable: false }, -// { name: 'price', type: 'decimal', nullable: false }, -// { name: 'category', type: 'string', nullable: true }, -// { name: 'created_at', type: 'timestamp', nullable: false } -// ] -// } - -// Get filtered records with pagination -const records = await tableClient.getRecords('products', { - page: 1, - pageSize: 20, - filter: { - category: 'electronics', - status: 'active' - } +const apiClient = new ApiClient({ + baseUrl: 'https://your-api-url.com', + schema: 'your_schema', + enableCache: true, // Enable caching (default: false) + cacheMaxAge: 5 * 60 * 1000 // Cache expiry time in ms (default: 5 minutes) }); -console.log('Total records:', records.pagination.total); -console.log('Current page:', records.pagination.page); -console.log('Records:', records.data); - -// Get a single record by ID -const product = await tableClient.getRecordById('products', 123); -console.log('Product details:', product); +// First call hits the network and is cached +const first = await apiClient.get('/api/blog/posts', { page: 1 }); -// Get localized records with automatic field localization -const localizedRecords = await tableClient.getRecords('products', { - page: 1, - pageSize: 20, - filter: { - category: 'electronics' - }, - locale: 'az', // Get records localized to Azerbaijani - defaultLocale: 'en' // Fall back to English if Azerbaijani translation is not available -}); +// Second identical call within cacheMaxAge is served from cache +const second = await apiClient.get('/api/blog/posts', { page: 1 }); -console.log('Localized records:', localizedRecords.data); -// The data will automatically have all localized fields (nameAz, descriptionAz, etc.) -// converted to their base names (name, description) with the localized values +// Bypass the cache for one request +const fresh = await apiClient.get('/api/blog/posts', { page: 1 }, {}, { skipCache: true }); -// Get a single localized record by ID -const localizedProduct = await tableClient.getRecordById('products', 123, { - locale: 'ru', // Get the record localized to Russian - defaultLocale: 'en' // Fall back to English if Russian translation is not available -}); - -console.log('Localized product:', localizedProduct); -// The product will have all localized fields automatically converted +// Clear cache entries for one endpoint prefix, or everything +apiClient.clearCache('/api/blog/posts'); +apiClient.clearCache(); ``` -#### Working with Dynamic Tables +Write requests are never cached, and they do not invalidate cached `GET` responses. Call `clearCache` after a mutation if you need the next read to be fresh. -The TableClient is particularly useful for applications that need to work with dynamic data structures that might change over time. Here are some common use cases: +## 🔐 Authentication -1. **Dynamic Admin Dashboards**: Build flexible admin interfaces that can adapt to changing data models -2. **Content Management Systems**: Retrieve structured content from various tables -3. **Data Visualization**: Fetch data for charts and reports from different data sources -4. **Custom Data-Driven Applications**: Work with application-specific data tables +The client does not manage credentials. Public endpoints need none. For the Admin API, pass whatever header your backend expects through the `headers` argument of the `ApiClient` methods: -#### Response Format +```javascript +const authHeaders = { Authorization: `Bearer ${token}` }; + +const posts = await apiClient.get('/api/admin/blog/posts', { page: 1 }, authHeaders); +await apiClient.put(`/api/admin/blog/posts/${id}`, { status: 'draft' }, authHeaders); +``` -The `getRecords` method returns data in the following format: +The `BlogClient` admin methods do not take a `headers` argument. If you prefer them, wrap the `ApiClient` so the header is added on every request: ```javascript -{ - data: [ - { id: 1, name: 'Product 1', price: 29.99, ... }, - { id: 2, name: 'Product 2', price: 49.99, ... }, - // ... - ], - pagination; { - total: 157, // Total number of records - page;1, // Current page - pageSize; 20, // Records per page - totalPages; 8 // Total number of pages +class AuthenticatedApiClient extends ApiClient { + constructor(config, token) { + super(config); + this.token = token; + } + + _createHeaders(additionalHeaders = {}) { + return super._createHeaders({ Authorization: `Bearer ${this.token}`, ...additionalHeaders }); } } + +const blogClient = new BlogClient(new AuthenticatedApiClient({ baseUrl, schema }, token)); +await blogClient.adminPublishPost(42); ``` ## ❌ Error Handling -All methods return promises that can throw errors. The errors include enhanced properties for better debugging: +All methods return promises. When the server responds with a non-2xx status, the rejection is an `Error` with extra properties: | Property | Type | Description | |----------|------|-------------| | `status` | number | HTTP status code (e.g., 404, 500) | -| `statusText` | string | HTTP status text (e.g., "Not Found", "Internal Server Error") | -| `message` | string | Human-readable error message | +| `statusText` | string | HTTP status text (e.g., "Not Found") | +| `message` | string | Message from the response body (`message` or `error`), or a default for the status code | | `url` | string | The URL that caused the error | -| `details` | object | Additional error details if available from the API | +| `details` | object | `details` or `errors` from the response body, or `null` | + +A request that exceeds `timeout` rejects with a plain `Error` whose message is `Request timeout after ms`; it has no `status`. Network failures reject with the underlying `fetch` error. ### Common Error Status Codes | Status Code | Description | Common Causes | |-------------|-------------|--------------| | 400 | Bad Request | Invalid parameters or malformed request | -| 401 | Unauthorized | Authentication required | +| 401 | Unauthorized | Authentication required (Admin API) | | 403 | Forbidden | Insufficient permissions | | 404 | Not Found | Resource doesn't exist | | 429 | Too Many Requests | Rate limit exceeded | | 500 | Server Error | Internal server error | | 503 | Service Unavailable | Server is temporarily unavailable | -### Example Error Handling +### Example ```javascript try { const posts = await blogClient.getPosts(); - // Process posts } catch (error) { - // Check for specific status codes if (error.status === 404) { console.error('Resource not found'); } else if (error.status === 429) { @@ -658,10 +542,7 @@ try { } else if (error.status >= 500) { console.error('Server error. Please try again later.'); } else { - // General error handling console.error(`Error: ${error.message}`); - - // Log detailed information for debugging console.debug({ status: error.status, statusText: error.statusText, @@ -672,78 +553,227 @@ try { } ``` +Client-side validation failures (for example calling `getPostById` without an ID) reject with an `Error` that has a descriptive message and no `status`. + ## 🌐 Multilingual Support -Many methods accept a `lang` parameter to retrieve content in a specific language. This is useful for applications that need to support multiple languages. +There are three layers of language handling: -### Example Usage +1. **Public blog endpoints** accept a `lang` option and return a flat object in that locale. The backend falls back to the `Accept-Language` header and then to `en`. +2. **Admin blog endpoints** exchange translation maps keyed by locale, so a single call reads or writes every language. +3. **Dynamic tables** store translations as suffixed columns; pass `locale` to `TableClient` (or use the [Localization Utility](#localization-utility)) to collapse them on the client. ```javascript -// Get blog posts in Spanish -const spanishPosts = await blogClient.getPosts({ - lang: 'es', - page: 1, - page_size: 10 +// Public blog content in Spanish +const spanishPosts = await blogClient.getPosts({ lang: 'es', page: 1, page_size: 10 }); +const frenchCategory = await blogClient.getCategoryBySlug('technology', { lang: 'fr' }); + +// Admin: create a post with two translations +await blogClient.adminCreatePost({ + category_id: 1, + author_id: 1, + status: 'draft', + translations: [ + { locale: 'en', title: 'Hello', content: 'World' }, + { locale: 'az', title: 'Salam', content: 'Dünya' } + ] }); -// Get a category in French -const frenchCategory = await blogClient.getCategoryBySlug('technology', { - lang: 'fr' -}); +// Tables: localize records client-side +const products = await tableClient.getRecords('products', { locale: 'az', defaultLocale: 'en' }); +``` -// Get a blog post in German -const germanPost = await blogClient.getPostBySlug('welcome-post', { - lang: 'de', - include_tags: true -}); +### Multi-tenancy + +Every request carries an `X-Schema` header. Set it at construction time or switch tenants at runtime: + +```javascript +const apiClient = new ApiClient({ baseUrl, schema: 'tenant_a' }); +apiClient.setSchema('tenant_b'); +``` + +## 🐘 PHP Client + +The `php/` directory contains a read-only PHP 8 implementation with the same surface as the JavaScript client for public blog content, tables, and localization. + +### Installation + +The package name is `keytd/dynamic-backend-client-php`. It is not yet on Packagist, so install it from this repository: + +**VCS repository (recommended)** + +```json +{ + "repositories": [ + { "type": "vcs", "url": "https://github.com/KeyTechAndDesign/dynamic-backend-client" } + ], + "require": { + "php": ">=8.0", + "keytd/dynamic-backend-client-php": "dev-main" + } +} +``` + +```bash +composer update keytd/dynamic-backend-client-php +``` + +**Local path repository** + +```json +{ + "repositories": [ + { "type": "path", "url": "../dynamic-backend-client" } + ], + "require": { + "keytd/dynamic-backend-client-php": "dev-main" + } +} +``` + +**Copy-only** + +Copy the `php/` directory into your project and map the namespace in your own `composer.json`: + +```json +{ + "autoload": { + "psr-4": { + "KeyTD\\DynamicBackendClient\\": "php/" + } + } +} +``` + +Then run `composer dump-autoload`. + +Once the package is published, `composer require keytd/dynamic-backend-client-php` will work directly. + +### Quick Start + +```php + 'https://your-api-url.com', + 'schema' => 'your_schema', + 'timeout' => 30000, // milliseconds + 'enableCache' => true, +]); + +$blog = new BlogClient($apiClient); +$table = new TableClient($apiClient); + +// Fetch posts +$posts = $blog->getPosts([ + 'page' => 1, + 'page_size' => 10, + 'status' => 'published', + 'include_tags' => true, + 'lang' => 'en', +]); + +// Fetch records from a table with localization +$records = $table->getRecords('products', [ + 'page' => 1, + 'pageSize' => 10, + 'filter' => ['category' => 'electronics'], + 'locale' => 'en', + 'defaultLocale' => 'en', +]); + +// Fetch comments for a post +$comments = $blog->getCommentsByPostId(123, [ + 'status' => 'approved', // default + 'lang' => 'en', +]); + +// Fetch a single comment by ID +$comment = $blog->getCommentById(456, ['lang' => 'en']); ``` +### Status + +- **Scope**: GET-only. `ApiClient` (cURL-based HTTP, timeout, `X-Schema` header, optional in-memory caching), `BlogClient`, `TableClient`, and `LocalizeUtil`. +- **Not included**: `POST`, `PUT`, `DELETE`, the Blog Admin API, and comment submission. Use the JavaScript client for write operations. +- **Endpoint differences**: The PHP `BlogClient` still targets the V1 comment routes (`/blog/comments/post/{id}` and `/blog/comments/{id}`) and accepts a `status` option, whereas the JavaScript client uses the V2 route `/blog/posts/{id}/comments`. +- **Requirements**: PHP 8.0 or newer with `ext-curl` enabled. +- **Tests**: PHPUnit suite with stubbed transport (no network). Run with `composer install && composer test`. + +### Troubleshooting + +- **Class not found**: Make sure `vendor/autoload.php` is required. With the copy-only approach, confirm the PSR-4 mapping points at `php/` and run `composer dump-autoload`. +- **cURL errors**: Enable `ext-curl` (`php-curl` on most Linux distributions, or uncomment the extension in `php.ini` on Windows). +- **Timeouts**: `timeout` is in milliseconds. +- **Caching**: Pass `enableCache => true` and optionally `cacheMaxAge` (ms) to `ApiClient`. + +### Publishing to Packagist (maintainers) + +Packagist can consume this monorepo directly because `composer.json` sits at the repository root. `.gitattributes` marks JavaScript sources, tests, and tooling as `export-ignore`, so Composer dist archives contain only the PHP files. + +1. Confirm `composer.json` is correct (package name, license, PSR-4 autoload). +2. Tag a release and push the tag, for example `git tag v0.1.0 && git push origin v0.1.0`. +3. Submit the repository URL at https://packagist.org. +4. Enable the GitHub auto-update hook on the Packagist package page so new tags are picked up automatically. + +A separate repository is only worth it if you want independent issue tracking or a different release cadence for PHP and JavaScript. + ## 🔄 Compatibility -### Version Requirements +### Runtime Requirements -| Dependency | Minimum Version | -|------------|-----------------| -| Node.js | 14.0.0 or higher | +| Dependency | Minimum Version | Notes | +|------------|-----------------|-------| +| Node.js | 14.0.0 | Native `fetch` and `AbortController` are available from Node.js 18. On 14 and 16, provide a global `fetch` polyfill (for example `node-fetch` or `undici`). | +| PHP | 8.0 | `ext-curl` required | ### Browser Support -This library is compatible with any modern JavaScript environment that supports: +The JavaScript client runs in any environment with `Promise`, `fetch`, `AbortController`, and ES2015 support. That covers all modern browsers, Node.js 18+, and frameworks such as React, Vue, Angular, and Next.js. -- Promises -- Fetch API -- ES6 features +### Module Format -This includes modern browsers, Node.js applications, and frameworks like React, Vue, Angular, Next.js, etc. +The package ships untranspiled ES module sources (`index.js` and `lib/`). Use a bundler or an ESM-aware runtime, or transpile with Babel as the test setup does. ### TypeScript Support -Complete TypeScript definitions are included for better development experience and type safety. +Type definitions are included in `types/index.d.ts` and cover `ApiClient`, `BlogClient` (public and admin), and `TableClient`. -## 👥 Contributing +## 🧪 Development -Contributions are welcome! Please feel free to submit a Pull Request. +```bash +git clone https://github.com/KeyTechAndDesign/dynamic-backend-client.git +cd dynamic-backend-client +npm install -### Development Setup +npm test # Run the Jest suite +npm run test:watch # Re-run on change +npm run test:coverage # Coverage report for lib/ -1. Clone the repository - ```bash - git clone https://github.com/KeyTechAndDesign/dynamic-backend-client.git - cd dynamic-backend-client - ``` +npm run docs # Generate JSDoc HTML into docs/ +npm run docs:serve # Serve the generated docs locally -2. Install dependencies - ```bash - npm install - ``` +composer install && composer test # PHP tests +``` -3. Make your changes and test them +Tests mock the network and live in [`tests/`](tests/README.md); the CI workflow runs them on Node.js 14, 16, and 18. -4. Submit a pull request with a detailed description of your changes +## 📖 Related Documentation -### Code Style +- [Examples](examples/README.md) - Runnable scripts for basic usage, blog operations, table operations, and advanced features +- [Blog System V2 API](BLOG_V2_API.md) - Endpoint reference for the public and admin blog APIs +- [Blog System V2 Schema](BLOG_V2_SCHEMA.md) - Database schema and translation model behind the blog API +- [Changelog](CHANGELOG.md) +- [Contributing Guide](CONTRIBUTING.md) + +## 👥 Contributing -Please follow the existing code style and include appropriate documentation for any new features. +Contributions are welcome. Read the [Contributing Guide](CONTRIBUTING.md) for the pull request process, coding style, and testing expectations, then open a pull request with a clear description of your change. ## 📄 License