@objectql/server 1.7.2 → 1.7.3

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/src/server.ts CHANGED
@@ -58,17 +58,27 @@ export class ObjectQLServer {
58
58
  switch (req.op) {
59
59
  case 'find':
60
60
  result = await repo.find(req.args);
61
- break;
61
+ // For find operations, return items array with pagination metadata
62
+ return this.buildListResponse(result, req.args, repo);
62
63
  case 'findOne':
63
64
  // Support both string ID and query object
64
65
  result = await repo.findOne(req.args);
65
- break;
66
+ if (result) {
67
+ return { ...result, '@type': req.object };
68
+ }
69
+ return result;
66
70
  case 'create':
67
71
  result = await repo.create(req.args);
68
- break;
72
+ if (result) {
73
+ return { ...result, '@type': req.object };
74
+ }
75
+ return result;
69
76
  case 'update':
70
77
  result = await repo.update(req.args.id, req.args.data);
71
- break;
78
+ if (result) {
79
+ return { ...result, '@type': req.object };
80
+ }
81
+ return result;
72
82
  case 'delete':
73
83
  result = await repo.delete(req.args.id);
74
84
  if (!result) {
@@ -77,12 +87,15 @@ export class ObjectQLServer {
77
87
  `Record with id '${req.args.id}' not found for delete`
78
88
  );
79
89
  }
80
- // Return standardized delete response on success
81
- result = { id: req.args.id, deleted: true };
82
- break;
90
+ // Return standardized delete response with object type
91
+ return {
92
+ id: req.args.id,
93
+ deleted: true,
94
+ '@type': req.object
95
+ };
83
96
  case 'count':
84
97
  result = await repo.count(req.args);
85
- break;
98
+ return { count: result, '@type': req.object };
86
99
  case 'action':
87
100
  // Map generic args to ActionContext
88
101
  result = await app.executeAction(req.object, req.args.action, {
@@ -90,7 +103,10 @@ export class ObjectQLServer {
90
103
  id: req.args.id,
91
104
  input: req.args.input || req.args.params // Support both for convenience
92
105
  });
93
- break;
106
+ if (result && typeof result === 'object') {
107
+ return { ...result, '@type': req.object };
108
+ }
109
+ return result;
94
110
  default:
95
111
  return this.errorResponse(
96
112
  ErrorCode.INVALID_REQUEST,
@@ -98,13 +114,44 @@ export class ObjectQLServer {
98
114
  );
99
115
  }
100
116
 
101
- return { data: result };
102
-
103
117
  } catch (e: any) {
104
118
  return this.handleError(e);
105
119
  }
106
120
  }
107
121
 
122
+ /**
123
+ * Build a standardized list response with pagination metadata
124
+ */
125
+ private async buildListResponse(items: any[], args: any, repo: any): Promise<ObjectQLResponse> {
126
+ const response: ObjectQLResponse = {
127
+ items
128
+ };
129
+
130
+ // Calculate pagination metadata if limit/skip are present
131
+ if (args && (args.limit || args.skip)) {
132
+ const skip = args.skip || 0;
133
+ const limit = args.limit || items.length;
134
+
135
+ // Get total count - use the same arguments as the query to ensure consistency
136
+ const total = await repo.count(args || {});
137
+
138
+ const size = limit;
139
+ const page = limit > 0 ? Math.floor(skip / limit) + 1 : 1;
140
+ const pages = limit > 0 ? Math.ceil(total / limit) : 1;
141
+ const has_next = skip + items.length < total;
142
+
143
+ response.meta = {
144
+ total,
145
+ page,
146
+ size,
147
+ pages,
148
+ has_next
149
+ };
150
+ }
151
+
152
+ return response;
153
+ }
154
+
108
155
  /**
109
156
  * Handle errors and convert them to appropriate error responses
110
157
  */
package/src/types.ts CHANGED
@@ -60,19 +60,35 @@ export interface ErrorDetails {
60
60
  [key: string]: unknown;
61
61
  }
62
62
 
63
+ /**
64
+ * Pagination metadata
65
+ */
66
+ export interface PaginationMeta {
67
+ total: number; // Total number of records
68
+ page?: number; // Current page number (1-indexed, e.g. page 1 corresponds to skip=0)
69
+ size?: number; // Number of items per page
70
+ pages?: number; // Total number of pages
71
+ has_next?: boolean; // Whether there is a next page
72
+ }
73
+
63
74
  /**
64
75
  * ObjectQL API response
65
76
  */
66
77
  export interface ObjectQLResponse {
67
- data?: any;
78
+ // For list operations (find)
79
+ items?: any[];
80
+
81
+ // Pagination metadata (for list operations)
82
+ meta?: PaginationMeta;
83
+
84
+ // Error information
68
85
  error?: {
69
86
  code: ErrorCode | string;
70
87
  message: string;
71
- details?: ErrorDetails;
72
- };
73
- meta?: {
74
- total?: number;
75
- page?: number;
76
- per_page?: number;
88
+ details?: ErrorDetails | any; // Allow flexible details structure
77
89
  };
90
+
91
+ // For single item operations, the response is the object itself with '@type' field
92
+ // This allows any additional fields from the actual data object
93
+ [key: string]: any;
78
94
  }
@@ -0,0 +1,247 @@
1
+ /**
2
+ * Integration Example: Demonstrates the new standardized API response format
3
+ *
4
+ * This file is for documentation purposes and shows how the API responses
5
+ * look with the new standardized format.
6
+ */
7
+
8
+ import { ObjectQL } from '@objectql/core';
9
+ import { createRESTHandler } from '../src/adapters/rest';
10
+ import { Driver } from '@objectql/types';
11
+
12
+ // Example: Setting up ObjectQL with a simple in-memory driver
13
+ class InMemoryDriver implements Driver {
14
+ private data: Record<string, any[]> = {};
15
+
16
+ async init() {}
17
+
18
+ async find(objectName: string, query: any) {
19
+ let items = this.data[objectName] || [];
20
+
21
+ // Apply skip and limit for pagination
22
+ if (query) {
23
+ if (query.skip) {
24
+ items = items.slice(query.skip);
25
+ }
26
+ if (query.limit) {
27
+ items = items.slice(0, query.limit);
28
+ }
29
+ }
30
+
31
+ return items;
32
+ }
33
+
34
+ async findOne(objectName: string, id: string | number) {
35
+ const items = this.data[objectName] || [];
36
+ return items.find(item => item.id === String(id)) || null;
37
+ }
38
+
39
+ async create(objectName: string, data: any) {
40
+ if (!this.data[objectName]) {
41
+ this.data[objectName] = [];
42
+ }
43
+ const newItem = { ...data, id: String(Date.now()) };
44
+ this.data[objectName].push(newItem);
45
+ return newItem;
46
+ }
47
+
48
+ async update(objectName: string, id: string | number, data: any) {
49
+ const items = this.data[objectName] || [];
50
+ const index = items.findIndex(item => item.id === String(id));
51
+ if (index >= 0) {
52
+ this.data[objectName][index] = { ...items[index], ...data };
53
+ return this.data[objectName][index];
54
+ }
55
+ return null;
56
+ }
57
+
58
+ async delete(objectName: string, id: string | number) {
59
+ const items = this.data[objectName] || [];
60
+ const index = items.findIndex(item => item.id === String(id));
61
+ if (index >= 0) {
62
+ this.data[objectName].splice(index, 1);
63
+ return 1;
64
+ }
65
+ return 0;
66
+ }
67
+
68
+ async count(objectName: string) {
69
+ return (this.data[objectName] || []).length;
70
+ }
71
+
72
+ // Seed some sample data
73
+ seed(objectName: string, items: any[]) {
74
+ this.data[objectName] = items;
75
+ }
76
+ }
77
+
78
+ // Initialize ObjectQL
79
+ const driver = new InMemoryDriver();
80
+ const app = new ObjectQL({
81
+ datasources: {
82
+ default: driver
83
+ }
84
+ });
85
+
86
+ // Register a simple object schema
87
+ app.metadata.register('object', {
88
+ type: 'object',
89
+ id: 'contract',
90
+ content: {
91
+ name: 'contract',
92
+ label: 'Contract',
93
+ fields: {
94
+ name: { type: 'text', label: 'Contract Name' },
95
+ amount: { type: 'number', label: 'Amount' },
96
+ status: { type: 'text', label: 'Status' }
97
+ }
98
+ }
99
+ });
100
+
101
+ // Seed sample data
102
+ driver.seed('contract', [
103
+ { id: '1', name: 'Contract A', amount: 5000, status: 'active' },
104
+ { id: '2', name: 'Contract B', amount: 3000, status: 'pending' },
105
+ { id: '3', name: 'Contract C', amount: 7500, status: 'active' },
106
+ { id: '4', name: 'Contract D', amount: 2000, status: 'completed' },
107
+ { id: '5', name: 'Contract E', amount: 9000, status: 'active' },
108
+ ]);
109
+
110
+ /**
111
+ * Example 1: List all contracts (no pagination)
112
+ *
113
+ * Request: GET /api/data/contract
114
+ *
115
+ * Response:
116
+ * {
117
+ * "items": [
118
+ * { "id": "1", "name": "Contract A", "amount": 5000, "status": "active" },
119
+ * { "id": "2", "name": "Contract B", "amount": 3000, "status": "pending" },
120
+ * { "id": "3", "name": "Contract C", "amount": 7500, "status": "active" },
121
+ * { "id": "4", "name": "Contract D", "amount": 2000, "status": "completed" },
122
+ * { "id": "5", "name": "Contract E", "amount": 9000, "status": "active" }
123
+ * ]
124
+ * }
125
+ */
126
+
127
+ /**
128
+ * Example 2: List contracts with pagination (first page)
129
+ *
130
+ * Request: GET /api/data/contract?limit=2&skip=0
131
+ *
132
+ * Response:
133
+ * {
134
+ * "items": [
135
+ * { "id": "1", "name": "Contract A", "amount": 5000, "status": "active" },
136
+ * { "id": "2", "name": "Contract B", "amount": 3000, "status": "pending" }
137
+ * ],
138
+ * "meta": {
139
+ * "total": 5,
140
+ * "page": 1,
141
+ * "size": 2,
142
+ * "pages": 3,
143
+ * "has_next": true
144
+ * }
145
+ * }
146
+ */
147
+
148
+ /**
149
+ * Example 3: List contracts with pagination (second page)
150
+ *
151
+ * Request: GET /api/data/contract?limit=2&skip=2
152
+ *
153
+ * Response:
154
+ * {
155
+ * "items": [
156
+ * { "id": "3", "name": "Contract C", "amount": 7500, "status": "active" },
157
+ * { "id": "4", "name": "Contract D", "amount": 2000, "status": "completed" }
158
+ * ],
159
+ * "meta": {
160
+ * "total": 5,
161
+ * "page": 2,
162
+ * "size": 2,
163
+ * "pages": 3,
164
+ * "has_next": true
165
+ * }
166
+ * }
167
+ */
168
+
169
+ /**
170
+ * Example 4: Get a single contract
171
+ *
172
+ * Request: GET /api/data/contract/1
173
+ *
174
+ * Response:
175
+ * {
176
+ * "data": {
177
+ * "id": "1",
178
+ * "name": "Contract A",
179
+ * "amount": 5000,
180
+ * "status": "active"
181
+ * }
182
+ * }
183
+ */
184
+
185
+ /**
186
+ * Example 5: Create a new contract
187
+ *
188
+ * Request: POST /api/data/contract
189
+ * Body: { "name": "Contract F", "amount": 4500, "status": "pending" }
190
+ *
191
+ * Response:
192
+ * {
193
+ * "data": {
194
+ * "id": "6",
195
+ * "name": "Contract F",
196
+ * "amount": 4500,
197
+ * "status": "pending"
198
+ * }
199
+ * }
200
+ */
201
+
202
+ /**
203
+ * Example 6: Update a contract
204
+ *
205
+ * Request: PUT /api/data/contract/1
206
+ * Body: { "status": "completed" }
207
+ *
208
+ * Response:
209
+ * {
210
+ * "data": {
211
+ * "id": "1",
212
+ * "name": "Contract A",
213
+ * "amount": 5000,
214
+ * "status": "completed"
215
+ * }
216
+ * }
217
+ */
218
+
219
+ /**
220
+ * Example 7: Delete a contract
221
+ *
222
+ * Request: DELETE /api/data/contract/1
223
+ *
224
+ * Response:
225
+ * {
226
+ * "data": {
227
+ * "id": "1",
228
+ * "deleted": true
229
+ * }
230
+ * }
231
+ */
232
+
233
+ /**
234
+ * Example 8: Error response (not found)
235
+ *
236
+ * Request: GET /api/data/contract/999
237
+ *
238
+ * Response:
239
+ * {
240
+ * "error": {
241
+ * "code": "NOT_FOUND",
242
+ * "message": "Record not found"
243
+ * }
244
+ * }
245
+ */
246
+
247
+ export { app, driver };
package/test/node.test.ts CHANGED
@@ -60,7 +60,7 @@ describe('Node Adapter', () => {
60
60
 
61
61
  expect(response.status).toBe(200);
62
62
  expect(response.body).toEqual({
63
- data: [{ id: 1, name: 'Alice' }]
63
+ items: [{ id: 1, name: 'Alice' }]
64
64
  });
65
65
  });
66
66
 
@@ -78,7 +78,9 @@ describe('Node Adapter', () => {
78
78
 
79
79
  expect(response.status).toBe(200);
80
80
  expect(response.body).toEqual({
81
- data: { id: 2, name: 'Bob' }
81
+ id: 2,
82
+ name: 'Bob',
83
+ '@type': 'user'
82
84
  });
83
85
  });
84
86
 
package/test/rest.test.ts CHANGED
@@ -17,7 +17,19 @@ class MockDriver implements Driver {
17
17
  async init() {}
18
18
 
19
19
  async find(objectName: string, query: any) {
20
- return this.data[objectName] || [];
20
+ let items = this.data[objectName] || [];
21
+
22
+ // Apply skip and limit if provided
23
+ if (query) {
24
+ if (query.skip) {
25
+ items = items.slice(query.skip);
26
+ }
27
+ if (query.limit) {
28
+ items = items.slice(0, query.limit);
29
+ }
30
+ }
31
+
32
+ return items;
21
33
  }
22
34
 
23
35
  async findOne(objectName: string, id: string | number, query?: any, options?: any) {
@@ -101,8 +113,8 @@ describe('REST API Adapter', () => {
101
113
  .set('Accept', 'application/json');
102
114
 
103
115
  expect(response.status).toBe(200);
104
- expect(response.body.data).toHaveLength(2);
105
- expect(response.body.data[0].name).toBe('Alice');
116
+ expect(response.body.items).toHaveLength(2);
117
+ expect(response.body.items[0].name).toBe('Alice');
106
118
  });
107
119
 
108
120
  it('should handle GET /api/data/:object/:id - Get single record', async () => {
@@ -111,7 +123,8 @@ describe('REST API Adapter', () => {
111
123
  .set('Accept', 'application/json');
112
124
 
113
125
  expect(response.status).toBe(200);
114
- expect(response.body.data.name).toBe('Alice');
126
+ expect(response.body.name).toBe('Alice');
127
+ expect(response.body['@type']).toBe('user');
115
128
  });
116
129
 
117
130
  it('should handle POST /api/data/:object - Create record', async () => {
@@ -121,8 +134,9 @@ describe('REST API Adapter', () => {
121
134
  .set('Accept', 'application/json');
122
135
 
123
136
  expect(response.status).toBe(201);
124
- expect(response.body.data.name).toBe('Charlie');
125
- expect(response.body.data._id).toBeDefined();
137
+ expect(response.body.name).toBe('Charlie');
138
+ expect(response.body._id).toBeDefined();
139
+ expect(response.body['@type']).toBe('user');
126
140
  });
127
141
 
128
142
  it('should handle PUT /api/data/:object/:id - Update record', async () => {
@@ -140,7 +154,8 @@ describe('REST API Adapter', () => {
140
154
  .set('Accept', 'application/json');
141
155
 
142
156
  expect(response.status).toBe(200);
143
- expect(response.body.data.deleted).toBe(true);
157
+ expect(response.body.deleted).toBe(true);
158
+ expect(response.body['@type']).toBe('user');
144
159
  });
145
160
 
146
161
  it('should return 404 for non-existent object', async () => {
@@ -161,4 +176,29 @@ describe('REST API Adapter', () => {
161
176
  expect(response.status).toBe(400);
162
177
  expect(response.body.error.code).toBe('INVALID_REQUEST');
163
178
  });
179
+
180
+ it('should include pagination metadata with limit and skip', async () => {
181
+ const response = await request(server)
182
+ .get('/api/data/user?limit=1&skip=0')
183
+ .set('Accept', 'application/json');
184
+
185
+ expect(response.status).toBe(200);
186
+ expect(response.body.items).toBeDefined();
187
+ expect(response.body.meta).toBeDefined();
188
+ expect(response.body.meta.total).toBe(2);
189
+ expect(response.body.meta.page).toBe(1);
190
+ expect(response.body.meta.size).toBe(1);
191
+ expect(response.body.meta.pages).toBe(2);
192
+ expect(response.body.meta.has_next).toBe(true);
193
+ });
194
+
195
+ it('should calculate pagination metadata correctly for second page', async () => {
196
+ const response = await request(server)
197
+ .get('/api/data/user?limit=1&skip=1')
198
+ .set('Accept', 'application/json');
199
+
200
+ expect(response.status).toBe(200);
201
+ expect(response.body.meta.page).toBe(2);
202
+ expect(response.body.meta.has_next).toBe(false);
203
+ });
164
204
  });