Skip to content

Commit cc32130

Browse files
committed
feat(api-console): add per-project API Console with scoped discovery and endpoint execution
1 parent e921add commit cc32130

20 files changed

Lines changed: 263 additions & 135 deletions

apps/studio/src/components/ApiConsolePage.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,9 +63,9 @@ interface RequestHistoryEntry {
6363

6464
let nextParamId = 1;
6565

66-
export function ApiConsolePage() {
66+
export function ApiConsolePage({ projectId }: { projectId?: string } = {}) {
6767
const client = useClient();
68-
const { groups, loading: discovering, refresh } = useApiDiscovery();
68+
const { groups, loading: discovering, refresh } = useApiDiscovery(projectId);
6969

7070
const [searchQuery, setSearchQuery] = useState('');
7171
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());

apps/studio/src/components/app-sidebar.tsx

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -528,8 +528,15 @@ export function AppSidebar({
528528
<SidebarMenuItem>
529529
<SidebarMenuButton
530530
tooltip="API Console"
531-
isActive={location.pathname === '/api-console'}
532-
onClick={() => navigate({ to: '/api-console' })}
531+
isActive={location.pathname.endsWith('/api-console')}
532+
onClick={() => {
533+
const projectId = params.projectId as string | undefined;
534+
if (projectId) {
535+
navigate({ to: '/projects/$projectId/api-console', params: { projectId } });
536+
} else {
537+
navigate({ to: '/api-console' });
538+
}
539+
}}
533540
>
534541
<Globe className="h-4 w-4" />
535542
<span>API Console</span>
@@ -541,8 +548,8 @@ export function AppSidebar({
541548
isActive={location.pathname.endsWith('/packages')}
542549
onClick={() => {
543550
const projectId = params.projectId as string | undefined;
544-
if (envId) {
545-
navigate({ to: '/projects/$projectId/packages', params: { projectId: envId } });
551+
if (projectId) {
552+
navigate({ to: '/projects/$projectId/packages', params: { projectId } });
546553
} else {
547554
navigate({ to: '/projects' });
548555
}

apps/studio/src/components/new-project-dialog.tsx

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,31 @@
1414
*/
1515

1616
import { useEffect, useState } from 'react';
17-
import type { Project } from '@objectstack/spec/cloud';
17+
/**
18+
* Canonical project row shape returned by the HTTP API.
19+
*
20+
* The dispatcher returns raw ObjectQL rows (snake_case column names) — no
21+
* camelCase translation layer. See `packages/runtime/src/http-dispatcher.ts`
22+
* `cleanProjectRow()`.
23+
*/
24+
type ProjectRow = {
25+
id: string;
26+
organization_id: string;
27+
display_name: string;
28+
is_default?: boolean;
29+
is_system?: boolean;
30+
status?: string;
31+
plan?: string;
32+
created_by?: string;
33+
created_at?: string;
34+
updated_at?: string;
35+
database_url?: string;
36+
database_driver?: string;
37+
storage_limit_mb?: number;
38+
provisioned_at?: string;
39+
hostname?: string;
40+
metadata?: Record<string, unknown>;
41+
};
1842
import {
1943
Dialog,
2044
DialogContent,
@@ -40,7 +64,7 @@ import { useActiveOrganizationId, useSession } from '@/hooks/useSession';
4064
export interface NewProjectDialogProps {
4165
open: boolean;
4266
onOpenChange: (open: boolean) => void;
43-
onCreated?: (env: Project) => void;
67+
onCreated?: (env: ProjectRow) => void;
4468
}
4569

4670
export function NewProjectDialog({
@@ -84,15 +108,15 @@ export function NewProjectDialog({
84108
}
85109
try {
86110
const res = await provision({
87-
organizationId: activeOrgId,
88-
createdBy: user?.id ?? '__session__',
89-
displayName: displayName.trim(),
111+
organization_id: activeOrgId,
112+
created_by: user?.id ?? '__session__',
113+
display_name: displayName.trim(),
90114
driver: driver || undefined,
91115
} as any);
92-
const project = (res?.project ?? res) as Project;
116+
const project = (res?.project ?? res) as ProjectRow;
93117
toast({
94118
title: 'Project provisioned',
95-
description: `${project.displayName} is ready.`,
119+
description: `${project.display_name} is ready.`,
96120
});
97121
reset();
98122
onOpenChange(false);

apps/studio/src/components/production-guard.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -65,17 +65,17 @@ export interface GuardOptions {
6565
/** Visual variant of the confirm button. Defaults to "destructive". */
6666
confirmVariant?: 'default' | 'destructive';
6767
/**
68-
* If true, the user must type the project's displayName (or slug) to
68+
* If true, the user must type the project's display_name (or slug) to
6969
* enable the confirm button. Use for the most destructive operations.
7070
*/
7171
requireTypedConfirmation?: boolean;
72-
/** The displayName/slug the user must type when requireTypedConfirmation. */
72+
/** The display_name/slug the user must type when requireTypedConfirmation. */
7373
typedConfirmationValue?: string;
7474
}
7575

7676
interface ActiveProjectSnapshot {
7777
projectType: ProjectType | undefined;
78-
displayName?: string;
78+
display_name?: string;
7979
}
8080

8181
interface ProductionGuardContextValue {
@@ -135,7 +135,7 @@ export function ProductionGuardProvider({ children }: { children: ReactNode }) {
135135
);
136136

137137
const expectedPhrase =
138-
opts?.typedConfirmationValue ?? active.displayName ?? '';
138+
opts?.typedConfirmationValue ?? active.display_name ?? '';
139139
const typedOk =
140140
!opts?.requireTypedConfirmation || typed.trim() === expectedPhrase.trim();
141141

@@ -163,7 +163,7 @@ export function ProductionGuardProvider({ children }: { children: ReactNode }) {
163163
<div className="my-2 flex items-center gap-2 rounded-md border border-red-500/30 bg-red-500/5 p-3 text-sm">
164164
<ProjectBadge projectType="production" />
165165
<span className="font-medium">
166-
{active.displayName ?? 'Production project'}
166+
{active.display_name ?? 'Production project'}
167167
</span>
168168
</div>
169169

apps/studio/src/components/project-switcher.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ export function ProjectSwitcher() {
5252
if (!q) return projects;
5353
return projects.filter(
5454
(e) =>
55-
e.displayName.toLowerCase().includes(q) ||
55+
e.display_name.toLowerCase().includes(q) ||
5656
e.id.toLowerCase().includes(q),
5757
);
5858
}, [projects, search]);
@@ -77,7 +77,7 @@ export function ProjectSwitcher() {
7777
>
7878
{active ? (
7979
<span className="max-w-[160px] truncate">
80-
{active.displayName}
80+
{active.display_name}
8181
</span>
8282
) : (
8383
<span className="text-muted-foreground">
@@ -123,9 +123,9 @@ export function ProjectSwitcher() {
123123
<div className="flex-1 min-w-0">
124124
<div className="flex items-center gap-2">
125125
<span className="truncate font-medium">
126-
{env.displayName}
126+
{env.display_name}
127127
</span>
128-
{env.isDefault && (
128+
{env.is_default && (
129129
<Badge
130130
variant="outline"
131131
className="h-4 px-1 text-[9px]"

apps/studio/src/hooks/use-api-discovery.ts

Lines changed: 36 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { useState, useEffect, useCallback } from 'react';
44
import { useClient } from '@objectstack/client-react';
5+
import { useScopedClient } from './useObjectStackClient';
56

67
// ─── Types ──────────────────────────────────────────────────────────
78

@@ -35,13 +36,6 @@ interface ServiceEndpointEntry {
3536
/** Metadata types that should be excluded from the endpoint tree */
3637
const EXCLUDED_META_TYPES = ['plugin', 'plugins', 'kind', 'package'];
3738

38-
const SYSTEM_ENDPOINTS: EndpointDef[] = [
39-
{ method: 'GET', path: '/api/v1/discovery', desc: 'API Discovery', group: 'System' },
40-
{ method: 'GET', path: '/api/v1/meta/types', desc: 'List metadata types', group: 'Metadata' },
41-
{ method: 'GET', path: '/api/v1/packages', desc: 'List packages', group: 'System' },
42-
{ method: 'GET', path: '/api/v1/health', desc: 'Health check', group: 'System' },
43-
];
44-
4539
/** Build auth endpoints from the discovered auth base path. */
4640
function buildAuthEndpoints(authBase: string): EndpointDef[] {
4741
return [
@@ -194,25 +188,41 @@ export function buildServiceEndpoints(serviceName: string, routePrefix: string):
194188

195189
// ─── Hook ───────────────────────────────────────────────────────────
196190

197-
export function useApiDiscovery() {
198-
const client = useClient();
191+
export function useApiDiscovery(projectId?: string) {
192+
const unscopedClient = useClient();
193+
const scopedClient = useScopedClient(projectId);
194+
const client: any = projectId ? scopedClient : unscopedClient;
199195
const [groups, setGroups] = useState<EndpointGroup[]>([]);
200196
const [allEndpoints, setAllEndpoints] = useState<EndpointDef[]>([]);
201197
const [loading, setLoading] = useState(true);
202198
const [error, setError] = useState<string | null>(null);
203199

204200
const discover = useCallback(async () => {
201+
// When projectId is provided but the scoped client hasn't resolved yet,
202+
// defer until the next render.
203+
if (projectId && !scopedClient) return;
204+
205205
setLoading(true);
206206
setError(null);
207207

208+
// Scope prefix for system endpoints; discovery already handles its own routes.
209+
const scopePrefix = projectId ? `/api/v1/projects/${projectId}` : '/api/v1';
210+
const discoveryUrl = `${scopePrefix}/discovery`;
211+
const systemEndpoints: EndpointDef[] = [
212+
{ method: 'GET', path: discoveryUrl, desc: 'API Discovery', group: 'System' },
213+
{ method: 'GET', path: `${scopePrefix}/meta/types`, desc: 'List metadata types', group: 'Metadata' },
214+
{ method: 'GET', path: `${scopePrefix}/packages`, desc: 'List packages', group: 'System' },
215+
{ method: 'GET', path: '/api/v1/health', desc: 'Health check', group: 'System' },
216+
];
217+
208218
try {
209219
// 1. Fetch discovery response — the source of truth for available services
210220
let authBase = '/api/v1/auth';
211221
let discoveredServices: Record<string, { enabled: boolean; route?: string }> = {};
212222
let discoveredRoutes: Record<string, string> = {};
213223

214224
try {
215-
const discRes = await fetch('/api/v1/discovery');
225+
const discRes = await fetch(discoveryUrl);
216226
if (discRes.ok) {
217227
const discData = await discRes.json();
218228
const data = discData?.data ?? discData;
@@ -238,10 +248,15 @@ export function useApiDiscovery() {
238248
const hasHandler = serviceInfo?.handlerReady
239249
?? (serviceInfo?.status === 'available' || serviceInfo?.status === 'degraded');
240250

241-
// Use route from discovery services, discovery routes map, or catalog default
242-
const routePrefix = serviceInfo?.route
251+
// Use route from discovery services, discovery routes map, or catalog default.
252+
// When in a project scope, rewrite unscoped catalog defaults so they include
253+
// the /projects/:projectId segment.
254+
const rawRoute = serviceInfo?.route
243255
?? discoveredRoutes[serviceName]
244256
?? catalog.defaultRoute;
257+
const routePrefix = projectId && rawRoute.startsWith('/api/v1/') && !rawRoute.includes('/projects/')
258+
? rawRoute.replace('/api/v1/', `/api/v1/projects/${projectId}/`)
259+
: rawRoute;
245260

246261
if (isEnabled && hasHandler) {
247262
serviceEndpoints.push(...buildServiceEndpoints(serviceName, routePrefix));
@@ -279,34 +294,34 @@ export function useApiDiscovery() {
279294

280295
// 5. Build dynamic data endpoints for each object
281296
const dataEndpoints: EndpointDef[] = objectNames.flatMap(name => [
282-
{ method: 'GET' as HttpMethod, path: `/api/v1/data/${name}`, desc: `List ${name}`, group: `Data: ${name}` },
283-
{ method: 'POST' as HttpMethod, path: `/api/v1/data/${name}`, desc: `Create ${name}`, group: `Data: ${name}`, bodyTemplate: { name: 'example' } },
284-
{ method: 'GET' as HttpMethod, path: `/api/v1/data/${name}/:id`, desc: `Get ${name} by ID`, group: `Data: ${name}` },
285-
{ method: 'PATCH' as HttpMethod, path: `/api/v1/data/${name}/:id`, desc: `Update ${name}`, group: `Data: ${name}`, bodyTemplate: { name: 'updated' } },
286-
{ method: 'DELETE' as HttpMethod, path: `/api/v1/data/${name}/:id`, desc: `Delete ${name}`, group: `Data: ${name}` },
297+
{ method: 'GET' as HttpMethod, path: `${scopePrefix}/data/${name}`, desc: `List ${name}`, group: `Data: ${name}` },
298+
{ method: 'POST' as HttpMethod, path: `${scopePrefix}/data/${name}`, desc: `Create ${name}`, group: `Data: ${name}`, bodyTemplate: { name: 'example' } },
299+
{ method: 'GET' as HttpMethod, path: `${scopePrefix}/data/${name}/:id`, desc: `Get ${name} by ID`, group: `Data: ${name}` },
300+
{ method: 'PATCH' as HttpMethod, path: `${scopePrefix}/data/${name}/:id`, desc: `Update ${name}`, group: `Data: ${name}`, bodyTemplate: { name: 'updated' } },
301+
{ method: 'DELETE' as HttpMethod, path: `${scopePrefix}/data/${name}/:id`, desc: `Delete ${name}`, group: `Data: ${name}` },
287302
]);
288303

289304
// 6. Build metadata endpoints for each type
290305
const metaEndpoints: EndpointDef[] = metaTypes
291306
.filter(t => !EXCLUDED_META_TYPES.includes(t))
292307
.map(type => ({
293308
method: 'GET' as HttpMethod,
294-
path: `/api/v1/meta/${type}`,
309+
path: `${scopePrefix}/meta/${type}`,
295310
desc: `List ${type} metadata`,
296311
group: 'Metadata',
297312
}));
298313

299314
// 7. Build per-object schema endpoints
300315
const schemaEndpoints: EndpointDef[] = objectNames.map(name => ({
301316
method: 'GET' as HttpMethod,
302-
path: `/api/v1/meta/object/${name}`,
317+
path: `${scopePrefix}/meta/object/${name}`,
303318
desc: `${name} schema`,
304319
group: 'Metadata',
305320
}));
306321

307322
// 8. Combine all endpoints
308323
const all = [
309-
...SYSTEM_ENDPOINTS,
324+
...systemEndpoints,
310325
...buildAuthEndpoints(authBase),
311326
...serviceEndpoints,
312327
...metaEndpoints,
@@ -346,7 +361,7 @@ export function useApiDiscovery() {
346361
} finally {
347362
setLoading(false);
348363
}
349-
}, [client]);
364+
}, [client, projectId, scopedClient]);
350365

351366
useEffect(() => { discover(); }, [discover]);
352367

apps/studio/src/hooks/useProjects.ts

Lines changed: 50 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,56 @@
1212

1313
import { useCallback, useEffect, useState } from 'react';
1414
import { useClient } from '@objectstack/client-react';
15-
import type { Project, ProjectDatabase, ProjectMember } from '@objectstack/spec/cloud';
1615
import { useActiveOrganizationId } from '@/hooks/useSession';
1716

17+
/**
18+
* Snake_case database metadata as returned by the HTTP dispatcher under
19+
* `GET /cloud/projects/:id`. See `http-dispatcher.ts` (the `database` block
20+
* it builds alongside the project row).
21+
*/
22+
export interface ProjectDatabaseRow {
23+
driver?: string;
24+
database_name?: string;
25+
database_url?: string;
26+
storage_limit_mb?: number;
27+
provisioned_at?: string;
28+
}
29+
30+
export interface ProjectMembershipRow {
31+
role?: string;
32+
user_id?: string;
33+
project_id?: string;
34+
}
35+
36+
/**
37+
* Canonical project row shape returned by the HTTP API (snake_case).
38+
*
39+
* The dispatcher returns raw ObjectQL rows; Studio consumes them verbatim
40+
* with no camelCase translation.
41+
*/
42+
export interface ProjectRow {
43+
id: string;
44+
organization_id: string;
45+
display_name: string;
46+
is_default?: boolean;
47+
is_system?: boolean;
48+
status?: string;
49+
plan?: string;
50+
created_by?: string;
51+
created_at?: string;
52+
updated_at?: string;
53+
database_url?: string;
54+
database_driver?: string;
55+
storage_limit_mb?: number;
56+
provisioned_at?: string;
57+
hostname?: string;
58+
metadata?: Record<string, unknown>;
59+
}
60+
1861
export interface ProjectDetail {
19-
project: Project;
20-
database?: ProjectDatabase;
21-
membership?: ProjectMember;
62+
project: ProjectRow;
63+
database?: ProjectDatabaseRow;
64+
membership?: ProjectMembershipRow;
2265
credential?: { id: string; status: string; activatedAt?: string };
2366
organization?: { id: string; name: string; displayName?: string };
2467
}
@@ -50,7 +93,7 @@ export function recallActiveProject(): string | null {
5093
export function useProjects() {
5194
const client = useClient() as any;
5295
const activeOrgId = useActiveOrganizationId();
53-
const [projects, setProjects] = useState<Project[]>([]);
96+
const [projects, setProjects] = useState<ProjectRow[]>([]);
5497
const [loading, setLoading] = useState(false);
5598
const [error, setError] = useState<Error | null>(null);
5699

@@ -63,8 +106,8 @@ export function useProjects() {
63106
setLoading(true);
64107
setError(null);
65108
try {
66-
const result = await client.projects.list({ organizationId: activeOrgId });
67-
setProjects((result?.projects as Project[]) ?? []);
109+
const result = await client.projects.list({ organization_id: activeOrgId });
110+
setProjects((result?.projects as ProjectRow[]) ?? []);
68111
} catch (err) {
69112
setError(err as Error);
70113
setProjects([]);

0 commit comments

Comments
 (0)