Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 63 additions & 1 deletion packages/client/src/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,12 @@ describe('ObjectStackClient', () => {
it('should make discovery request on connect', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ routes: { data: '/api/v1/data' } })
json: async () => ({
version: 'v1',
apiName: 'ObjectStack',
capabilities: ['metadata', 'data', 'ui'],
endpoints: {}
})
});

const client = new ObjectStackClient({
Expand All @@ -26,4 +31,61 @@ describe('ObjectStackClient', () => {
await client.connect();
expect(fetchMock).toHaveBeenCalledWith('http://localhost:3000/api/v1', expect.any(Object));
});

it('should get metadata types', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
types: ['object', 'plugin', 'view']
})
});

const client = new ObjectStackClient({
baseUrl: 'http://localhost:3000',
fetch: fetchMock
});

const result = await client.meta.getTypes();
expect(fetchMock).toHaveBeenCalledWith('http://localhost:3000/api/v1/meta', expect.any(Object));
expect(result.types).toEqual(['object', 'plugin', 'view']);
});

it('should get metadata items by type', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
type: 'object',
items: [{ name: 'customer' }, { name: 'order' }]
})
});

const client = new ObjectStackClient({
baseUrl: 'http://localhost:3000',
fetch: fetchMock
});

const result = await client.meta.getItems('object');
expect(fetchMock).toHaveBeenCalledWith('http://localhost:3000/api/v1/meta/object', expect.any(Object));
expect(result.type).toBe('object');
expect(result.items).toHaveLength(2);
});

it('should get metadata item by type and name', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
name: 'customer',
fields: []
})
});

const client = new ObjectStackClient({
baseUrl: 'http://localhost:3000',
fetch: fetchMock
});

const result = await client.meta.getItem('object', 'customer');
expect(fetchMock).toHaveBeenCalledWith('http://localhost:3000/api/v1/meta/object/customer', expect.any(Object));
expect(result.name).toBe('customer');
});
});
97 changes: 69 additions & 28 deletions packages/client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ import {
MetadataCacheRequest,
MetadataCacheResponse,
StandardErrorCode,
ErrorCategory
ErrorCategory,
GetDiscoveryResponse,
GetMetaTypesResponse,
GetMetaItemsResponse
} from '@objectstack/spec/api';
import { Logger, createLogger } from '@objectstack/core';

Expand All @@ -29,16 +32,11 @@ export interface ClientConfig {
debug?: boolean;
}

export interface DiscoveryResult {
routes: {
discovery: string;
metadata: string;
data: string;
auth: string;
ui: string;
};
capabilities?: Record<string, boolean>;
}
/**
* Discovery Result
* Re-export from @objectstack/spec/api for convenience
*/
export type DiscoveryResult = GetDiscoveryResponse;

export interface QueryOptions {
select?: string[]; // Simplified Selection
Expand Down Expand Up @@ -69,7 +67,7 @@ export class ObjectStackClient {
private baseUrl: string;
private token?: string;
private fetchImpl: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
private routes?: DiscoveryResult['routes'];
private discoveryInfo?: DiscoveryResult;
private logger: Logger;

constructor(config: ClientConfig) {
Expand All @@ -87,21 +85,21 @@ export class ObjectStackClient {
}

/**
* Initialize the client by discovering server capabilities and routes.
* Initialize the client by discovering server capabilities.
*/
async connect() {
this.logger.debug('Connecting to ObjectStack server', { baseUrl: this.baseUrl });

try {
// Connect to the discovery endpoint
// During boot, we might not know routes, so we check convention /api/v1 first
// Connect to the discovery endpoint at /api/v1
const res = await this.fetch(`${this.baseUrl}/api/v1`);

const data = await res.json();
this.routes = data.routes;
this.discoveryInfo = data;

this.logger.info('Connected to ObjectStack server', {
routes: Object.keys(data.routes || {}),
version: data.version,
apiName: data.apiName,
capabilities: data.capabilities
});

Expand All @@ -116,11 +114,47 @@ export class ObjectStackClient {
* Metadata Operations
*/
meta = {
/**
* Get all available metadata types
* Returns types like 'object', 'plugin', 'view', etc.
*/
getTypes: async (): Promise<GetMetaTypesResponse> => {
const route = this.getRoute('metadata');
const res = await this.fetch(`${this.baseUrl}${route}`);
return res.json();
},

/**
* Get all items of a specific metadata type
* @param type - Metadata type name (e.g., 'object', 'plugin')
*/
getItems: async (type: string): Promise<GetMetaItemsResponse> => {
const route = this.getRoute('metadata');
const res = await this.fetch(`${this.baseUrl}${route}/${type}`);
return res.json();
},

/**
* Get a specific object definition by name
* @deprecated Use `getItem('object', name)` instead for consistency with spec protocol
* @param name - Object name (snake_case identifier)
*/
getObject: async (name: string) => {
const route = this.getRoute('metadata');
const res = await this.fetch(`${this.baseUrl}${route}/object/${name}`);
return res.json();
},

/**
* Get a specific metadata item by type and name
* @param type - Metadata type (e.g., 'object', 'plugin')
* @param name - Item name (snake_case identifier)
*/
getItem: async (type: string, name: string) => {
const route = this.getRoute('metadata');
const res = await this.fetch(`${this.baseUrl}${route}/${type}/${name}`);
return res.json();
},

/**
* Get object metadata with cache support
Expand Down Expand Up @@ -407,16 +441,20 @@ export class ObjectStackClient {
return res;
}

private getRoute(key: keyof DiscoveryResult['routes']): string {
if (!this.routes) {
// Fallback for strictness, but we allow bootstrapping
this.logger.warn('Accessing route before connect()', {
route: key,
fallback: `/api/v1/${key}`
});
return `/api/v1/${key}`;
}
return this.routes[key] || `/api/v1/${key}`;
/**
* Get the conventional route path for a given API endpoint type
* ObjectStack uses standard conventions: /api/v1/data, /api/v1/meta, /api/v1/ui
*/
private getRoute(type: 'data' | 'metadata' | 'ui' | 'auth'): string {
// Use conventional ObjectStack API paths
const routeMap: Record<string, string> = {
data: '/api/v1/data',
metadata: '/api/v1/meta',
ui: '/api/v1/ui',
auth: '/api/v1/auth'
};

return routeMap[type] || `/api/v1/${type}`;
}
}

Expand All @@ -435,5 +473,8 @@ export type {
MetadataCacheRequest,
MetadataCacheResponse,
StandardErrorCode,
ErrorCategory
ErrorCategory,
GetDiscoveryResponse,
GetMetaTypesResponse,
GetMetaItemsResponse
} from '@objectstack/spec/api';
Loading