liferay-headless-sdk 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,432 @@
1
+ # Liferay Headless SDK
2
+
3
+ A production-ready JavaScript SDK that **dynamically generates** API client methods from Liferay's Swagger/OpenAPI specifications at runtime. Zero manual mapping required — point it at a Liferay instance and call APIs immediately.
4
+
5
+ ---
6
+
7
+ ## Table of Contents
8
+
9
+ - [Installation](#installation)
10
+ - [Quick Start](#quick-start)
11
+ - [Configuration](#configuration)
12
+ - [Authentication](#authentication)
13
+ - [Dynamic Method Usage](#dynamic-method-usage)
14
+ - [Pagination](#pagination)
15
+ - [Interceptors](#interceptors)
16
+ - [Error Handling](#error-handling)
17
+ - [CLI Tool](#cli-tool)
18
+ - [TypeScript](#typescript)
19
+ - [Advanced Usage](#advanced-usage)
20
+
21
+ ---
22
+
23
+ ## Installation
24
+
25
+ ```bash
26
+ npm install liferay-headless-sdk
27
+ # or
28
+ yarn add liferay-headless-sdk
29
+ ```
30
+
31
+ Node.js 18+ is required (uses native `fetch` and ES modules).
32
+
33
+ ---
34
+
35
+ ## Quick Start
36
+
37
+ ```js
38
+ import { LiferayHeadlessClient } from 'liferay-headless-sdk';
39
+
40
+ const client = new LiferayHeadlessClient({
41
+ baseUrl: 'https://your-liferay.com',
42
+ swaggerUrls: [
43
+ '/o/headless-delivery/v1.0/openapi.json',
44
+ '/o/headless-admin-user/v1.0/openapi.json',
45
+ ],
46
+ username: 'test@liferay.com',
47
+ password: 'test',
48
+ });
49
+
50
+ // Initialize (loads and parses OpenAPI schemas)
51
+ await client.init();
52
+
53
+ // Call generated methods
54
+ const { data: sites } = await client.headlessDelivery.site.getSites();
55
+ console.log(sites.items);
56
+ ```
57
+
58
+ ---
59
+
60
+ ## Configuration
61
+
62
+ ```js
63
+ const client = new LiferayHeadlessClient({
64
+ // Required
65
+ baseUrl: 'https://your-liferay.com',
66
+
67
+ // OpenAPI endpoints to load (relative or absolute URLs)
68
+ swaggerUrls: [
69
+ '/o/headless-delivery/v1.0/openapi.json',
70
+ '/o/headless-admin-user/v1.0/openapi.json',
71
+ '/o/headless-admin-content/v1.0/openapi.json',
72
+ '/o/object-admin/v1.0/openapi.json',
73
+ ],
74
+
75
+ // Auth — use one of the options below
76
+ username: 'test@liferay.com',
77
+ password: 'test',
78
+ // oauthToken: 'your-bearer-token',
79
+
80
+ // HTTP behavior
81
+ timeout: 30000, // ms — default 30s
82
+ retries: 2, // automatic retries on 5xx / network errors
83
+
84
+ // Lazy init via Proxy (default true)
85
+ // Set false if you want to call init() manually and avoid Proxy overhead
86
+ autoGenerate: true,
87
+ });
88
+ ```
89
+
90
+ ### Available Swagger Endpoints
91
+
92
+ | API | Path |
93
+ |-----|------|
94
+ | Headless Delivery | `/o/headless-delivery/v1.0/openapi.json` |
95
+ | Headless Admin User | `/o/headless-admin-user/v1.0/openapi.json` |
96
+ | Headless Admin Content | `/o/headless-admin-content/v1.0/openapi.json` |
97
+ | Object Admin | `/o/object-admin/v1.0/openapi.json` |
98
+ | All APIs (discovery) | `/o/api` |
99
+
100
+ ---
101
+
102
+ ## Authentication
103
+
104
+ ### Basic Auth (username + password)
105
+
106
+ ```js
107
+ const client = new LiferayHeadlessClient({
108
+ baseUrl: '...',
109
+ username: 'test@liferay.com',
110
+ password: 'test',
111
+ });
112
+ ```
113
+
114
+ ### OAuth2 Bearer Token
115
+
116
+ ```js
117
+ const client = new LiferayHeadlessClient({
118
+ baseUrl: '...',
119
+ oauthToken: 'eyJhbGciOiJSUzI1NiJ9...',
120
+ });
121
+ ```
122
+
123
+ ### Switching Auth Dynamically
124
+
125
+ ```js
126
+ // Switch to OAuth after initial Basic auth setup
127
+ client.setOAuthToken('new-access-token');
128
+
129
+ // Or switch back to Basic
130
+ client.setBasicAuth('admin@liferay.com', 'admin');
131
+
132
+ // Clear auth
133
+ client.clearAuth();
134
+ ```
135
+
136
+ ---
137
+
138
+ ## Dynamic Method Usage
139
+
140
+ After `init()`, service namespaces are accessible as properties of the client. The namespace name is derived from the OpenAPI tag (converted to camelCase).
141
+
142
+ ```js
143
+ await client.init();
144
+
145
+ // GET /v1.0/sites/{siteId}
146
+ const { data } = await client.headlessDelivery.site.getSite({siteId: 12345});
147
+
148
+ // GET /v1.0/structured-contents/{structuredContentId}
149
+ const { data: contents } = await client.headlessDelivery.structuredContent.getStructuredContent({
150
+ structuredContentId: 999,
151
+ });
152
+
153
+ // POST /v1.0/sites/{siteId}/structured-contents
154
+ await client.headlessDelivery.structuredContent.postSiteStructuredContent({
155
+ siteId: 12345,
156
+ body: {
157
+ title: 'My Article',
158
+ contentStructureId: 67890,
159
+ contentFields: [],
160
+ },
161
+ });
162
+
163
+ // PUT /v1.0/structured-contents/{structuredContentId}
164
+ await client.headlessDelivery.structuredContent.putStructuredContent({
165
+ structuredContentId: 999,
166
+ body: { title: 'Updated Title' },
167
+ });
168
+
169
+ // DELETE /v1.0/structured-contents/{structuredContentId}
170
+ await client.headlessDelivery.structuredContent.deleteStructuredContent({
171
+ structuredContentId: 999,
172
+ });
173
+ ```
174
+
175
+ ### Parameter Mapping
176
+
177
+ The SDK auto-maps parameters from the single `params` object:
178
+
179
+ | Param type | How to pass |
180
+ |------------|-------------|
181
+ | Path variable `{siteId}` | `{ siteId: 12345 }` |
182
+ | Query string `?page=1` | `{ page: 1 }` |
183
+ | Request body | `{ body: { ... } }` |
184
+ | Extra headers | `{ headers: { 'X-Custom': 'value' } }` |
185
+
186
+ Any extra keys not defined in the OpenAPI spec are passed as query parameters.
187
+
188
+ ### Discovering Available Methods
189
+
190
+ ```js
191
+ // List all service namespaces
192
+ console.log(client.getServiceNames());
193
+ // ['headlessDelivery', 'headlessAdminUser', ...]
194
+
195
+ // List methods in a namespace
196
+ console.log(client.getMethodNames('headlessDelivery'));
197
+ // ['getSites', 'getStructuredContents', 'createStructuredContent', ...]
198
+ ```
199
+
200
+ ---
201
+
202
+ ## Pagination
203
+
204
+ Liferay returns paginated responses with `{ items, page, pageSize, totalCount, lastPage }`.
205
+
206
+ ### Iterate all pages lazily
207
+
208
+ ```js
209
+ import { iteratePages } from 'liferay-headless-sdk';
210
+
211
+ await client.init();
212
+
213
+ for await (const site of iteratePages(client.headlessDelivery.getSites, { pageSize: 50 })) {
214
+ console.log(site.name);
215
+ }
216
+ ```
217
+
218
+ ### Collect all items into an array
219
+
220
+ ```js
221
+ import { collectAllPages } from 'liferay-headless-sdk';
222
+
223
+ const allUsers = await collectAllPages(client.headlessAdminUser.getUsers, { pageSize: 100 });
224
+ ```
225
+
226
+ ### Fetch a specific page
227
+
228
+ ```js
229
+ import { getPage } from 'liferay-headless-sdk';
230
+
231
+ const page2 = await getPage(client.headlessDelivery.getSites, 2, 20);
232
+ console.log(page2.items, page2.totalCount, page2.lastPage);
233
+ ```
234
+
235
+ ---
236
+
237
+ ## Interceptors
238
+
239
+ Interceptors run on every request/response before it is returned to your code.
240
+
241
+ ### Logging
242
+
243
+ ```js
244
+ client.addRequestInterceptor((config) => {
245
+ console.log(`→ ${config.method} ${config.path}`);
246
+ return config;
247
+ });
248
+
249
+ client.addResponseInterceptor((response) => {
250
+ console.log(`← ${response.status}`);
251
+ return response;
252
+ });
253
+ ```
254
+
255
+ ### Adding headers
256
+
257
+ ```js
258
+ client.addRequestInterceptor((config) => ({
259
+ ...config,
260
+ headers: { ...config.headers, 'X-Correlation-ID': crypto.randomUUID() },
261
+ }));
262
+ ```
263
+
264
+ ### Token refresh
265
+
266
+ ```js
267
+ client.addResponseInterceptor(async (response) => {
268
+ if (response.status === 401) {
269
+ const newToken = await refreshAccessToken();
270
+ client.setOAuthToken(newToken);
271
+ }
272
+ return response;
273
+ });
274
+ ```
275
+
276
+ ---
277
+
278
+ ## Error Handling
279
+
280
+ ```js
281
+ import { LiferayAPIError, LiferayNetworkError, LiferayTimeoutError } from 'liferay-headless-sdk';
282
+
283
+ try {
284
+ const { data } = await client.headlessDelivery.getSites();
285
+ } catch (err) {
286
+ if (err instanceof LiferayAPIError) {
287
+ // HTTP error response from Liferay
288
+ console.error(`API Error ${err.statusCode}: ${err.message}`);
289
+ console.error('Endpoint:', err.endpoint);
290
+ console.error('Response body:', err.responseBody);
291
+ } else if (err instanceof LiferayTimeoutError) {
292
+ console.error(`Timed out after ${err.timeoutMs}ms on ${err.endpoint}`);
293
+ } else if (err instanceof LiferayNetworkError) {
294
+ console.error(`Network failure on ${err.endpoint}:`, err.message);
295
+ } else {
296
+ throw err;
297
+ }
298
+ }
299
+ ```
300
+
301
+ ### Error properties
302
+
303
+ | Class | Properties |
304
+ |-------|-----------|
305
+ | `LiferayAPIError` | `statusCode`, `message`, `endpoint`, `requestPayload`, `responseBody` |
306
+ | `LiferayNetworkError` | `message`, `endpoint`, `cause` |
307
+ | `LiferayTimeoutError` | `message`, `endpoint`, `timeoutMs` |
308
+
309
+ 4xx errors are **not retried**. 5xx and network errors are retried up to `retries` times with exponential backoff.
310
+
311
+ ---
312
+
313
+ ## CLI Tool
314
+
315
+ Generate a static SDK by pre-fetching Swagger definitions:
316
+
317
+ ```bash
318
+ npx liferay-sdk-cli generate \
319
+ --baseUrl https://your-liferay.com \
320
+ --output ./generated-sdk \
321
+ --username test@liferay.com \
322
+ --password test
323
+ ```
324
+
325
+ ### Options
326
+
327
+ | Flag | Description | Default |
328
+ |------|-------------|---------|
329
+ | `--baseUrl` | Liferay instance URL | *required* |
330
+ | `--output` | Output directory | `./generated-sdk` |
331
+ | `--username` | Basic Auth username | |
332
+ | `--password` | Basic Auth password | |
333
+ | `--token` | OAuth2 bearer token | |
334
+ | `--swagger` | Comma-separated list of OpenAPI paths | all 4 default endpoints |
335
+
336
+ ### Custom Swagger URLs
337
+
338
+ ```bash
339
+ npx liferay-sdk-cli generate \
340
+ --baseUrl https://your-liferay.com \
341
+ --swagger /o/headless-delivery/v1.0/openapi.json,/o/my-custom-api/v1.0/openapi.json
342
+ ```
343
+
344
+ ---
345
+
346
+ ## TypeScript
347
+
348
+ Full TypeScript declarations are included. The dynamic service namespaces are typed with an index signature:
349
+
350
+ ```ts
351
+ import { LiferayHeadlessClient, LiferayAPIError } from 'liferay-headless-sdk';
352
+
353
+ const client = new LiferayHeadlessClient({
354
+ baseUrl: 'https://your-liferay.com',
355
+ username: 'test@liferay.com',
356
+ password: 'test',
357
+ swaggerUrls: ['/o/headless-delivery/v1.0/openapi.json'],
358
+ });
359
+
360
+ await client.init();
361
+
362
+ // Dynamically accessed namespaces return Record<string, ApiMethod>
363
+ const service = client['headlessDelivery'] as Record<string, Function>;
364
+ const result = await service['getSites']();
365
+ ```
366
+
367
+ For strongly-typed wrappers, use the CLI to generate static service modules with explicit types.
368
+
369
+ ---
370
+
371
+ ## Advanced Usage
372
+
373
+ ### Load schemas on demand
374
+
375
+ ```js
376
+ const client = new LiferayHeadlessClient({
377
+ baseUrl: '...',
378
+ swaggerUrls: [], // Start with no schemas
379
+ autoGenerate: false,
380
+ username: 'test@liferay.com',
381
+ password: 'test',
382
+ });
383
+
384
+ // Load only what you need
385
+ await client.loadSchema('/o/headless-delivery/v1.0/openapi.json');
386
+ const { data } = await client.headlessDelivery.getSites();
387
+ ```
388
+
389
+ ### Raw HTTP access
390
+
391
+ ```js
392
+ const { data } = await client.request({
393
+ method: 'GET',
394
+ path: '/o/headless-delivery/v1.0/sites',
395
+ query: { page: 1, pageSize: 5 },
396
+ });
397
+ ```
398
+
399
+ ### Cache management
400
+
401
+ ```js
402
+ // Clear all cached schemas (forces re-fetch on next init)
403
+ client.clearSchemaCache();
404
+ await client.init();
405
+ ```
406
+
407
+ ---
408
+
409
+ ## File Structure
410
+
411
+ ```
412
+ liferay-sdk/
413
+ ├── index.js — Public exports
414
+ ├── index.d.ts — TypeScript declarations
415
+ ├── client.js — LiferayHeadlessClient class
416
+ ├── swagger-loader.js — OpenAPI schema fetching & caching
417
+ ├── api-generator.js — Dynamic method generation from schemas
418
+ ├── http.js — HTTP transport (fetch, retry, timeout)
419
+ ├── auth.js — Basic Auth & OAuth2 manager
420
+ ├── errors.js — LiferayAPIError, LiferayNetworkError, LiferayTimeoutError
421
+ ├── pagination.js — iteratePages, collectAllPages, getPage
422
+ ├── utils.js — URL building, camelCase conversion, etc.
423
+ ├── cli.js — liferay-sdk-cli tool
424
+ ├── package.json
425
+ └── README.md
426
+ ```
427
+
428
+ ---
429
+
430
+ ## License
431
+
432
+ MIT
package/package.json ADDED
@@ -0,0 +1,73 @@
1
+ {
2
+ "name": "liferay-headless-sdk",
3
+ "version": "1.0.0",
4
+ "description": "A production-ready JavaScript SDK dynamically generated from Liferay Headless API Swagger/OpenAPI specifications.",
5
+ "type": "module",
6
+ "main": "./src/index.js",
7
+ "module": "./src/index.js",
8
+ "types": "./src/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "import": "./src/index.js",
12
+ "types": "./src/index.d.ts"
13
+ }
14
+ },
15
+ "bin": {
16
+ "liferay-sdk-cli": "./src/cli.js"
17
+ },
18
+ "files": [
19
+ "index.js",
20
+ "index.d.ts",
21
+ "client.js",
22
+ "swagger-loader.js",
23
+ "api-generator.js",
24
+ "http.js",
25
+ "auth.js",
26
+ "errors.js",
27
+ "pagination.js",
28
+ "utils.js",
29
+ "cli.js",
30
+ "README.md"
31
+ ],
32
+ "scripts": {
33
+ "test": "node --experimental-vm-modules node_modules/.bin/jest",
34
+ "lint": "eslint .",
35
+ "build": "echo 'No build step required for ES modules'"
36
+ },
37
+ "keywords": [
38
+ "liferay",
39
+ "headless",
40
+ "api",
41
+ "sdk",
42
+ "openapi",
43
+ "swagger",
44
+ "rest",
45
+ "client"
46
+ ],
47
+ "author": "",
48
+ "license": "MIT",
49
+ "engines": {
50
+ "node": ">=18.0.0"
51
+ },
52
+ "devDependencies": {
53
+ "eslint": "^8.57.0",
54
+ "jest": "^29.7.0"
55
+ },
56
+ "eslintConfig": {
57
+ "env": {
58
+ "browser": true,
59
+ "es2022": true,
60
+ "node": true
61
+ },
62
+ "parserOptions": {
63
+ "ecmaVersion": 2022,
64
+ "sourceType": "module"
65
+ },
66
+ "rules": {
67
+ "no-console": "warn",
68
+ "no-unused-vars": "warn",
69
+ "prefer-const": "error",
70
+ "no-var": "error"
71
+ }
72
+ }
73
+ }
@@ -0,0 +1,192 @@
1
+ /**
2
+ * @fileoverview Generates dynamic API service modules from parsed OpenAPI schemas.
3
+ * Groups operations by tag, converts operationIds to method names, and wires up
4
+ * path/query/body parameters automatically.
5
+ */
6
+
7
+ import { operationIdToMethodName, tagToPropertyName, interpolatePath, joinUrl } from './utils.js';
8
+
9
+ /** HTTP methods that typically carry a request body */
10
+ const BODY_METHODS = new Set(['post', 'put', 'patch']);
11
+
12
+ /**
13
+ * Parses an OpenAPI schema and groups all operations by their first tag.
14
+ *
15
+ * @param {object} schema - Parsed OpenAPI/Swagger JSON
16
+ * @returns {Map<string, Array<OperationDef>>} Tag → list of operation definitions
17
+ */
18
+ export function parseOperationsByTag(schema) {
19
+ const tagMap = new Map();
20
+ const paths = schema.paths || {};
21
+ const url = schema.servers[0].url;
22
+
23
+ for (const [pathTemplate, pathItem] of Object.entries(paths)) {
24
+ for (const method of ['get', 'post', 'put', 'patch', 'delete']) {
25
+ const operation = pathItem[method];
26
+ if (!operation) continue;
27
+
28
+ const tags = (operation.tags && operation.tags.length > 0)
29
+ ? operation.tags
30
+ : ['default'];
31
+
32
+ const primaryTag = tags[0];
33
+ const tagKey = tagToPropertyName(primaryTag);
34
+
35
+ if (!tagMap.has(tagKey)) {
36
+ tagMap.set(tagKey, []);
37
+ }
38
+
39
+ tagMap.get(tagKey).push({
40
+ url,
41
+ method,
42
+ pathTemplate,
43
+ operationId: operation.operationId,
44
+ parameters: operation.parameters || [],
45
+ requestBody: operation.requestBody || null,
46
+ summary: operation.summary || '',
47
+ description: operation.description || '',
48
+ });
49
+ }
50
+ }
51
+
52
+ return tagMap;
53
+ }
54
+
55
+ /**
56
+ * @typedef {object} OperationDef
57
+ * @property {string} method
58
+ * @property {string} pathTemplate
59
+ * @property {string} operationId
60
+ * @property {Array} parameters
61
+ * @property {object|null} requestBody
62
+ * @property {string} summary
63
+ * @property {string} description
64
+ */
65
+
66
+ /**
67
+ * Generates a JavaScript function for a single OpenAPI operation.
68
+ *
69
+ * The generated function accepts a single `params` object and automatically:
70
+ * - Substitutes path variables
71
+ * - Passes remaining params as query string
72
+ * - Accepts a `body` key for the request body
73
+ * - Accepts a `headers` key for extra headers
74
+ *
75
+ * @param {OperationDef} operation
76
+ * @param {import('./http.js').HttpClient} httpClient
77
+ * @returns {Function}
78
+ */
79
+ export function buildOperationMethod(operation, httpClient) {
80
+ const { url, method, pathTemplate, operationId, parameters } = operation;
81
+
82
+ // Identify path parameter names
83
+ const pathParamNames = new Set(
84
+ parameters.filter((p) => p.in === 'path').map((p) => p.name)
85
+ );
86
+
87
+ // Identify query parameter names
88
+ const queryParamNames = new Set(
89
+ parameters.filter((p) => p.in === 'query').map((p) => p.name)
90
+ );
91
+
92
+ /**
93
+ * Dynamically generated API method.
94
+ * @param {object} [params={}]
95
+ * @param {*} [params.body] - Request body (for POST/PUT/PATCH)
96
+ * @param {Record<string, string>} [params.headers] - Additional headers
97
+ * @returns {Promise<{ status: number, data: * }>}
98
+ */
99
+ const generatedMethod = async function (params = {}) {
100
+ const { body, headers, ...rest } = params;
101
+
102
+ // Separate path params from query params
103
+ const pathParams = {};
104
+ const queryParams = {};
105
+ const extraParams = {};
106
+
107
+ for (const [key, value] of Object.entries(rest)) {
108
+ if (pathParamNames.has(key)) {
109
+ pathParams[key] = value;
110
+ } else if (queryParamNames.has(key)) {
111
+ queryParams[key] = value;
112
+ } else {
113
+ // Unknown params fall through as query params
114
+ extraParams[key] = value;
115
+ }
116
+ }
117
+
118
+ const { path } = interpolatePath(joinUrl(url, pathTemplate), pathParams);
119
+ const mergedQuery = { ...queryParams, ...extraParams };
120
+
121
+ // For methods with bodies, use body param; otherwise ignore
122
+ const requestBody = BODY_METHODS.has(method) ? body : undefined;
123
+
124
+ return httpClient.request({
125
+ method: method.toUpperCase(),
126
+ path,
127
+ query: mergedQuery,
128
+ body: requestBody,
129
+ headers,
130
+ });
131
+ };
132
+
133
+ // Attach metadata to the function for introspection
134
+ Object.defineProperty(generatedMethod, 'name', {
135
+ value: operationIdToMethodName(operationId) || `${method}${pathTemplate}`,
136
+ writable: false,
137
+ });
138
+ generatedMethod._operationId = operationId;
139
+ generatedMethod._method = method;
140
+ generatedMethod._path = pathTemplate;
141
+ generatedMethod._summary = operation.summary;
142
+
143
+ return generatedMethod;
144
+ }
145
+
146
+ /**
147
+ * Builds a service module object (a plain object of named methods) for a
148
+ * single OpenAPI tag group.
149
+ *
150
+ * @param {Array<OperationDef>} operations
151
+ * @param {import('./http.js').HttpClient} httpClient
152
+ * @returns {Record<string, Function>}
153
+ */
154
+ export function buildServiceModule(operations, httpClient) {
155
+ const module = {};
156
+
157
+ for (const operation of operations) {
158
+ const methodName = operationIdToMethodName(operation.operationId);
159
+ if (!methodName) {
160
+ console.warn(`[LiferaySDK] Skipping operation with no operationId at ${operation.method.toUpperCase()} ${operation.pathTemplate}`);
161
+ continue;
162
+ }
163
+ if (module[methodName]) {
164
+ // Deduplicate: append HTTP method suffix to avoid collision
165
+ const uniqueName = `${methodName}_${operation.method}`;
166
+ module[uniqueName] = buildOperationMethod(operation, httpClient);
167
+ } else {
168
+ module[methodName] = buildOperationMethod(operation, httpClient);
169
+ }
170
+ }
171
+
172
+ return module;
173
+ }
174
+
175
+ /**
176
+ * Generates all service modules from a parsed OpenAPI schema.
177
+ *
178
+ * @param {object} schema - Parsed OpenAPI JSON
179
+ * @param {import('./http.js').HttpClient} httpClient
180
+ * @returns {Record<string, Record<string, Function>>} Tag → service module
181
+ */
182
+ export function generateServicesFromSchema(schema, httpClient) {
183
+ const tagMap = parseOperationsByTag(schema);
184
+ const service = tagToPropertyName(schema.info.title);
185
+ const services = {[service]:{}};
186
+
187
+ for (const [tagKey, operations] of tagMap.entries()) {
188
+ services[service][tagKey] = buildServiceModule(operations, httpClient);
189
+ }
190
+
191
+ return services;
192
+ }