Skip to content

Commit 3856bdd

Browse files
committed
feat: Add ApiDataSource and ValueDataSource adapters, enhance resolveDataSource function, and implement useViewData hook for reactive data fetching
1 parent 34bf9d8 commit 3856bdd

12 files changed

Lines changed: 2286 additions & 8 deletions

File tree

ROADMAP.md

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -241,20 +241,21 @@ WidgetManifest: {
241241

242242
---
243243

244-
#### 8. ViewData API Provider
244+
#### 8. ViewData API Provider
245245

246246
**Spec Requirement:**
247247
```typescript
248248
{ provider: 'api', read?: HttpRequest, write?: HttpRequest }
249249
```
250250

251-
**Current State:** Type defined but not fully implemented in plugin-form.
251+
**Current State:** Fully implemented with three DataSource adapters and a unified React hook.
252252

253-
**Tasks:**
254-
- [ ] Full HttpRequest support (method, url, headers, body)
255-
- [ ] API read integration in plugin-form
256-
- [ ] API write integration in plugin-form
257-
- [ ] Error handling and loading states
253+
**Completed:**
254+
- [x] `ApiDataSource` — HTTP-based adapter for `provider: 'api'` (GET/POST/PATCH/DELETE, URL building, response normalization for array/{data}/{items}/{results}/{records}/{value} shapes, header merging, configurable fetch)
255+
- [x] `ValueDataSource` — In-memory adapter for `provider: 'value'` (filtering with $gt/$gte/$lt/$lte/$ne/$in/$contains, sorting, pagination, $search, full CRUD, auto-ID generation)
256+
- [x] `resolveDataSource()` — Factory routing `ViewData.provider` → correct adapter (`'api'` → ApiDataSource, `'value'` → ValueDataSource, `'object'` → context fallback)
257+
- [x] `useViewData` hook — React hook bridging ViewData → DataSource → reactive {data, loading, error, totalCount, refresh, fetchOne, hasMore}
258+
- [x] Full test coverage: 26 ApiDataSource + 34 ValueDataSource + 13 resolveDataSource + 15 useViewData tests (88 total)
258259

259260
---
260261

Lines changed: 349 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,349 @@
1+
/**
2+
* ObjectUI — ApiDataSource
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*
8+
* A DataSource adapter for the `provider: 'api'` ViewData mode.
9+
* Makes raw HTTP requests using the HttpRequest configs from ViewData.
10+
*/
11+
12+
import type {
13+
DataSource,
14+
QueryParams,
15+
QueryResult,
16+
HttpRequest,
17+
HttpMethod,
18+
} from '@object-ui/types';
19+
20+
// ---------------------------------------------------------------------------
21+
// Configuration
22+
// ---------------------------------------------------------------------------
23+
24+
export interface ApiDataSourceConfig {
25+
/** HttpRequest config for read operations (find, findOne) */
26+
read?: HttpRequest;
27+
/** HttpRequest config for write operations (create, update, delete) */
28+
write?: HttpRequest;
29+
/** Custom fetch implementation (defaults to globalThis.fetch) */
30+
fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
31+
/** Default headers applied to all requests */
32+
defaultHeaders?: Record<string, string>;
33+
}
34+
35+
// ---------------------------------------------------------------------------
36+
// Helpers
37+
// ---------------------------------------------------------------------------
38+
39+
/** Build a full URL with query params */
40+
function buildUrl(
41+
base: string,
42+
pathSuffix?: string,
43+
queryParams?: Record<string, unknown>,
44+
): string {
45+
let url = base;
46+
if (pathSuffix) {
47+
url = url.replace(/\/+$/, '') + '/' + pathSuffix.replace(/^\/+/, '');
48+
}
49+
50+
if (queryParams && Object.keys(queryParams).length > 0) {
51+
const search = new URLSearchParams();
52+
for (const [key, value] of Object.entries(queryParams)) {
53+
if (value !== undefined && value !== null) {
54+
search.set(key, String(value));
55+
}
56+
}
57+
const qs = search.toString();
58+
if (qs) {
59+
url += (url.includes('?') ? '&' : '?') + qs;
60+
}
61+
}
62+
63+
return url;
64+
}
65+
66+
/** Convert QueryParams to flat query string params */
67+
function queryParamsToRecord(params?: QueryParams): Record<string, unknown> {
68+
if (!params) return {};
69+
70+
const out: Record<string, unknown> = {};
71+
72+
if (params.$select?.length) {
73+
out.$select = params.$select.join(',');
74+
}
75+
if (params.$filter && Object.keys(params.$filter).length > 0) {
76+
out.$filter = JSON.stringify(params.$filter);
77+
}
78+
if (params.$orderby) {
79+
if (Array.isArray(params.$orderby)) {
80+
if (typeof params.$orderby[0] === 'string') {
81+
out.$orderby = (params.$orderby as string[]).join(',');
82+
} else {
83+
out.$orderby = (params.$orderby as Array<{ field: string; order?: string }>)
84+
.map((s) => `${s.field} ${s.order || 'asc'}`)
85+
.join(',');
86+
}
87+
} else {
88+
out.$orderby = Object.entries(params.$orderby)
89+
.map(([field, order]) => `${field} ${order}`)
90+
.join(',');
91+
}
92+
}
93+
if (params.$skip !== undefined) out.$skip = params.$skip;
94+
if (params.$top !== undefined) out.$top = params.$top;
95+
if (params.$expand?.length) out.$expand = params.$expand.join(',');
96+
if (params.$search) out.$search = params.$search;
97+
if (params.$count) out.$count = 'true';
98+
99+
return out;
100+
}
101+
102+
/** Merge two header objects, giving priority to the second */
103+
function mergeHeaders(
104+
base?: Record<string, string>,
105+
override?: Record<string, string>,
106+
): Record<string, string> {
107+
return { ...base, ...override };
108+
}
109+
110+
// ---------------------------------------------------------------------------
111+
// ApiDataSource
112+
// ---------------------------------------------------------------------------
113+
114+
/**
115+
* ApiDataSource — a DataSource adapter for raw HTTP APIs.
116+
*
117+
* Used when `ViewData.provider === 'api'`. The read and write HttpRequest
118+
* configs define the endpoints; all CRUD methods map onto HTTP verbs.
119+
*
120+
* Read operations use the `read` config, write operations use the `write` config.
121+
* Both fall back to each other when one is not provided.
122+
*
123+
* @example
124+
* ```ts
125+
* const ds = new ApiDataSource({
126+
* read: { url: '/api/contacts', method: 'GET' },
127+
* write: { url: '/api/contacts', method: 'POST' },
128+
* });
129+
*
130+
* const result = await ds.find('contacts', { $top: 10 });
131+
* ```
132+
*/
133+
export class ApiDataSource<T = any> implements DataSource<T> {
134+
private readConfig: HttpRequest | undefined;
135+
private writeConfig: HttpRequest | undefined;
136+
private fetchFn: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
137+
private defaultHeaders: Record<string, string>;
138+
139+
constructor(config: ApiDataSourceConfig) {
140+
this.readConfig = config.read;
141+
this.writeConfig = config.write;
142+
this.fetchFn = config.fetch ?? globalThis.fetch.bind(globalThis);
143+
this.defaultHeaders = config.defaultHeaders ?? {};
144+
}
145+
146+
// -----------------------------------------------------------------------
147+
// Internal request executor
148+
// -----------------------------------------------------------------------
149+
150+
private async request<R = any>(
151+
base: HttpRequest | undefined,
152+
options: {
153+
pathSuffix?: string;
154+
method?: HttpMethod;
155+
queryParams?: Record<string, unknown>;
156+
body?: unknown;
157+
headers?: Record<string, string>;
158+
} = {},
159+
): Promise<R> {
160+
if (!base) {
161+
throw new Error(
162+
'ApiDataSource: No HTTP configuration provided for this operation. ' +
163+
'Ensure the ViewData has read/write configs.',
164+
);
165+
}
166+
167+
const method = options.method ?? base.method ?? 'GET';
168+
169+
// Merge query params: base.params + extra queryParams
170+
const allQuery = {
171+
...(base.params as Record<string, unknown> | undefined),
172+
...options.queryParams,
173+
};
174+
175+
const url = buildUrl(base.url, options.pathSuffix, allQuery);
176+
177+
const headers = mergeHeaders(
178+
mergeHeaders(this.defaultHeaders, base.headers),
179+
options.headers,
180+
);
181+
182+
const init: RequestInit = {
183+
method,
184+
headers,
185+
};
186+
187+
// Attach body for non-GET methods
188+
if (options.body !== undefined && method !== 'GET') {
189+
if (
190+
options.body instanceof FormData ||
191+
options.body instanceof Blob
192+
) {
193+
init.body = options.body as FormData | Blob;
194+
} else if (typeof options.body === 'string') {
195+
init.body = options.body;
196+
if (!headers['Content-Type']) {
197+
headers['Content-Type'] = 'text/plain';
198+
}
199+
} else {
200+
init.body = JSON.stringify(options.body);
201+
if (!headers['Content-Type']) {
202+
headers['Content-Type'] = 'application/json';
203+
}
204+
}
205+
}
206+
207+
const response = await this.fetchFn(url, init);
208+
209+
if (!response.ok) {
210+
const text = await response.text().catch(() => '');
211+
throw new Error(
212+
`ApiDataSource: HTTP ${response.status} ${response.statusText}${text}`,
213+
);
214+
}
215+
216+
// Try to parse as JSON; fall back to empty object
217+
const contentType = response.headers.get('content-type') ?? '';
218+
if (contentType.includes('application/json')) {
219+
return response.json();
220+
}
221+
222+
// Non-JSON response — return text wrapped in an object
223+
const text = await response.text();
224+
return text as unknown as R;
225+
}
226+
227+
// -----------------------------------------------------------------------
228+
// DataSource interface
229+
// -----------------------------------------------------------------------
230+
231+
async find(_resource: string, params?: QueryParams): Promise<QueryResult<T>> {
232+
const queryParams = queryParamsToRecord(params);
233+
const raw = await this.request<any>(this.readConfig, {
234+
method: 'GET',
235+
queryParams,
236+
});
237+
238+
// Normalize: the API might return an array, an object with `data`, or a QueryResult
239+
return this.normalizeQueryResult(raw);
240+
}
241+
242+
async findOne(_resource: string, id: string | number, params?: QueryParams): Promise<T | null> {
243+
try {
244+
const queryParams = queryParamsToRecord(params);
245+
const raw = await this.request<T>(this.readConfig, {
246+
pathSuffix: String(id),
247+
method: 'GET',
248+
queryParams,
249+
});
250+
return raw ?? null;
251+
} catch {
252+
return null;
253+
}
254+
}
255+
256+
async create(_resource: string, data: Partial<T>): Promise<T> {
257+
const config = this.writeConfig ?? this.readConfig;
258+
return this.request<T>(config, {
259+
method: 'POST',
260+
body: data,
261+
});
262+
}
263+
264+
async update(_resource: string, id: string | number, data: Partial<T>): Promise<T> {
265+
const config = this.writeConfig ?? this.readConfig;
266+
return this.request<T>(config, {
267+
pathSuffix: String(id),
268+
method: 'PATCH',
269+
body: data,
270+
});
271+
}
272+
273+
async delete(_resource: string, id: string | number): Promise<boolean> {
274+
const config = this.writeConfig ?? this.readConfig;
275+
try {
276+
await this.request(config, {
277+
pathSuffix: String(id),
278+
method: 'DELETE',
279+
});
280+
return true;
281+
} catch {
282+
return false;
283+
}
284+
}
285+
286+
async getObjectSchema(_objectName: string): Promise<any> {
287+
// Generic API endpoints typically don't expose schema metadata.
288+
// Return a minimal stub so schema-dependent components don't crash.
289+
return { name: _objectName, fields: {} };
290+
}
291+
292+
async getView(_objectName: string, _viewId: string): Promise<any | null> {
293+
return null;
294+
}
295+
296+
async getApp(_appId: string): Promise<any | null> {
297+
return null;
298+
}
299+
300+
// -----------------------------------------------------------------------
301+
// Helpers
302+
// -----------------------------------------------------------------------
303+
304+
/**
305+
* Normalize various API response shapes into a QueryResult.
306+
*
307+
* Supported shapes:
308+
* - `T[]` → wrap in QueryResult
309+
* - `{ data: T[] }` → extract data
310+
* - `{ items: T[] }` → extract items
311+
* - `{ results: T[] }` → extract results
312+
* - `{ records: T[] }` → extract records (Salesforce-style)
313+
* - `{ value: T[] }` → extract value (OData-style)
314+
* - Full QueryResult (has data + totalCount) → return as-is
315+
*/
316+
private normalizeQueryResult(raw: any): QueryResult<T> {
317+
if (Array.isArray(raw)) {
318+
return { data: raw, total: raw.length };
319+
}
320+
321+
if (raw && typeof raw === 'object') {
322+
// Already a QueryResult
323+
if (Array.isArray(raw.data) && ('total' in raw || 'totalCount' in raw)) {
324+
return {
325+
data: raw.data,
326+
total: raw.total ?? raw.totalCount ?? raw.data.length,
327+
hasMore: raw.hasMore,
328+
cursor: raw.cursor,
329+
};
330+
}
331+
332+
// Common envelope patterns
333+
for (const key of ['data', 'items', 'results', 'records', 'value']) {
334+
if (Array.isArray(raw[key])) {
335+
return {
336+
data: raw[key],
337+
total: raw.total ?? raw.totalCount ?? raw.count ?? raw[key].length,
338+
hasMore: raw.hasMore ?? raw.hasNextPage,
339+
};
340+
}
341+
}
342+
343+
// Single-object response — wrap as array
344+
return { data: [raw as T], total: 1 };
345+
}
346+
347+
return { data: [], total: 0 };
348+
}
349+
}

0 commit comments

Comments
 (0)