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