reading from openapi spec
This commit is contained in:
@@ -1,24 +1,41 @@
|
|||||||
import * as React from 'react';
|
import * as React from "react";
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { AuthProvider, useAuth, AuthPage } from "../auth/src";
|
import { AuthProvider, useAuth, AuthPage } from "../auth/src";
|
||||||
import { UploadProvider } from "./providers/UploadProvider";
|
import { UploadProvider } from "./providers/UploadProvider";
|
||||||
import AdminLayout from "./components/AdminLayout";
|
import AdminLayout from "./components/AdminLayout";
|
||||||
import ResourceView from "./components/ResourceView";
|
import ResourceView from "./components/ResourceView";
|
||||||
import { config } from "./config";
|
import { getAppConfig } from "./config";
|
||||||
import { Box, Typography, Paper } from '@mui/material';
|
import { initializeApiClients } from "./api/client";
|
||||||
|
import { AppConfig } from "./types/config";
|
||||||
|
import { Box, Typography, Paper, CircularProgress } from "@mui/material";
|
||||||
import AppTheme from "../src/shared-theme/AppTheme";
|
import AppTheme from "../src/shared-theme/AppTheme";
|
||||||
|
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
|
// Create a context for the app config
|
||||||
|
export const ConfigContext = React.createContext<AppConfig | null>(null);
|
||||||
|
|
||||||
function Dashboard() {
|
function Dashboard() {
|
||||||
|
const config = React.useContext(ConfigContext);
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="h4" gutterBottom>Welcome to the Admin Panel</Typography>
|
<Typography variant="h4" gutterBottom>
|
||||||
<Typography variant="body1">Select a resource from the sidebar to manage data.</Typography>
|
Welcome to the Admin Panel
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body1">
|
||||||
|
Select a resource from the sidebar to manage data.
|
||||||
|
</Typography>
|
||||||
|
|
||||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))', gap: 3, mt: 4 }}>
|
<Box
|
||||||
{config.resources.map(res => (
|
sx={{
|
||||||
<Paper key={res.name} sx={{ p: 3, textAlign: 'center' }}>
|
display: "grid",
|
||||||
|
gridTemplateColumns: "repeat(auto-fill, minmax(200px, 1fr))",
|
||||||
|
gap: 3,
|
||||||
|
mt: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{config?.resources.map((res) => (
|
||||||
|
<Paper key={res.name} sx={{ p: 3, textAlign: "center" }}>
|
||||||
<Typography variant="h6">{res.pluralLabel}</Typography>
|
<Typography variant="h6">{res.pluralLabel}</Typography>
|
||||||
</Paper>
|
</Paper>
|
||||||
))}
|
))}
|
||||||
@@ -29,25 +46,29 @@ function Dashboard() {
|
|||||||
|
|
||||||
function AdminApp() {
|
function AdminApp() {
|
||||||
const { currentUser, login, logout, loading, error } = useAuth();
|
const { currentUser, login, logout, loading, error } = useAuth();
|
||||||
const [selectedResourceName, setSelectedResourceName] = React.useState<string | null>(null);
|
const config = React.useContext(ConfigContext);
|
||||||
|
const [selectedResourceName, setSelectedResourceName] = React.useState<
|
||||||
|
string | null
|
||||||
|
>(null);
|
||||||
|
|
||||||
if (!currentUser) {
|
if (!currentUser) {
|
||||||
return (
|
return (
|
||||||
<AuthPage
|
<AuthPage
|
||||||
mode="login"
|
mode="login"
|
||||||
login={login}
|
login={login}
|
||||||
register={async () => {
|
register={async () => {}} // Disable registration for Admin
|
||||||
}} // Disable registration for Admin
|
|
||||||
loading={loading}
|
loading={loading}
|
||||||
error={error}
|
error={error}
|
||||||
onSwitchMode={() => {
|
onSwitchMode={() => {}}
|
||||||
}} onBack={function (): void {
|
onBack={() => {}}
|
||||||
throw new Error("Function not implemented.");
|
currentUser={null}
|
||||||
}} currentUser={undefined} />
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedResource = config.resources.find(r => r.name === selectedResourceName);
|
const selectedResource = config?.resources.find(
|
||||||
|
(r) => r.name === selectedResourceName
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminLayout
|
<AdminLayout
|
||||||
@@ -55,6 +76,7 @@ function AdminApp() {
|
|||||||
onLogout={logout}
|
onLogout={logout}
|
||||||
selectedResourceName={selectedResourceName}
|
selectedResourceName={selectedResourceName}
|
||||||
onSelectResource={setSelectedResourceName}
|
onSelectResource={setSelectedResourceName}
|
||||||
|
resources={config?.resources || []}
|
||||||
>
|
>
|
||||||
{selectedResource ? (
|
{selectedResource ? (
|
||||||
<ResourceView key={selectedResource.name} config={selectedResource} />
|
<ResourceView key={selectedResource.name} config={selectedResource} />
|
||||||
@@ -66,14 +88,42 @@ function AdminApp() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
const [config, setConfig] = React.useState<AppConfig | null>(null);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
getAppConfig().then((cfg) => {
|
||||||
|
initializeApiClients(cfg.baseUrl, cfg.authBaseUrl);
|
||||||
|
setConfig(cfg);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
return (
|
||||||
|
<AppTheme>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
height: "100vh",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
</AppTheme>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppTheme>
|
<AppTheme>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<AuthProvider authBaseUrl={config.authBaseUrl}>
|
<ConfigContext.Provider value={config}>
|
||||||
<UploadProvider>
|
<AuthProvider authBaseUrl={config.authBaseUrl}>
|
||||||
<AdminApp />
|
<UploadProvider>
|
||||||
</UploadProvider>
|
<AdminApp />
|
||||||
</AuthProvider>
|
</UploadProvider>
|
||||||
|
</AuthProvider>
|
||||||
|
</ConfigContext.Provider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</AppTheme>
|
</AppTheme>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,43 @@
|
|||||||
|
import axios, { AxiosInstance } from "axios";
|
||||||
import { createApiClient } from "../../auth/src";
|
import { createApiClient } from "../../auth/src";
|
||||||
import { config } from "../config";
|
|
||||||
|
|
||||||
export const api = createApiClient(config.baseUrl);
|
/**
|
||||||
export const auth = createApiClient(config.authBaseUrl);
|
* We expose a singleton-like getter/setter for the API clients
|
||||||
|
*/
|
||||||
|
let _api: AxiosInstance | null = null;
|
||||||
|
let _auth: AxiosInstance | null = null;
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
get: (...args: Parameters<AxiosInstance["get"]>) => {
|
||||||
|
if (!_api) throw new Error("API client not initialized");
|
||||||
|
return _api.get(...args);
|
||||||
|
},
|
||||||
|
post: (...args: Parameters<AxiosInstance["post"]>) => {
|
||||||
|
if (!_api) throw new Error("API client not initialized");
|
||||||
|
return _api.post(...args);
|
||||||
|
},
|
||||||
|
put: (...args: Parameters<AxiosInstance["put"]>) => {
|
||||||
|
if (!_api) throw new Error("API client not initialized");
|
||||||
|
return _api.put(...args);
|
||||||
|
},
|
||||||
|
delete: (...args: Parameters<AxiosInstance["delete"]>) => {
|
||||||
|
if (!_api) throw new Error("API client not initialized");
|
||||||
|
return _api.delete(...args);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const auth = {
|
||||||
|
post: (...args: Parameters<AxiosInstance["post"]>) => {
|
||||||
|
if (!_auth) throw new Error("Auth client not initialized");
|
||||||
|
return _auth.post(...args);
|
||||||
|
},
|
||||||
|
get: (...args: Parameters<AxiosInstance["get"]>) => {
|
||||||
|
if (!_auth) throw new Error("Auth client not initialized");
|
||||||
|
return _auth.get(...args);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function initializeApiClients(baseUrl: string, authBaseUrl: string) {
|
||||||
|
_api = createApiClient(baseUrl);
|
||||||
|
_auth = createApiClient(authBaseUrl);
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
import TableViewIcon from '@mui/icons-material/TableView';
|
import TableViewIcon from '@mui/icons-material/TableView';
|
||||||
import DashboardIcon from '@mui/icons-material/Dashboard';
|
import DashboardIcon from '@mui/icons-material/Dashboard';
|
||||||
import LogoutIcon from '@mui/icons-material/Logout';
|
import LogoutIcon from '@mui/icons-material/Logout';
|
||||||
import { config } from '../config';
|
import { ResourceConfig } from '../types/config';
|
||||||
|
|
||||||
const drawerWidth = 240;
|
const drawerWidth = 240;
|
||||||
|
|
||||||
@@ -27,6 +27,7 @@ interface AdminLayoutProps {
|
|||||||
selectedResourceName: string | null;
|
selectedResourceName: string | null;
|
||||||
onLogout: () => void;
|
onLogout: () => void;
|
||||||
username?: string;
|
username?: string;
|
||||||
|
resources: ResourceConfig[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AdminLayout({
|
export default function AdminLayout({
|
||||||
@@ -34,7 +35,8 @@ export default function AdminLayout({
|
|||||||
onSelectResource,
|
onSelectResource,
|
||||||
selectedResourceName,
|
selectedResourceName,
|
||||||
onLogout,
|
onLogout,
|
||||||
username
|
username,
|
||||||
|
resources,
|
||||||
}: AdminLayoutProps) {
|
}: AdminLayoutProps) {
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex' }}>
|
<Box sx={{ display: 'flex' }}>
|
||||||
@@ -77,7 +79,7 @@ export default function AdminLayout({
|
|||||||
</List>
|
</List>
|
||||||
<Divider />
|
<Divider />
|
||||||
<List>
|
<List>
|
||||||
{config.resources.map((res) => (
|
{resources.map((res) => (
|
||||||
<ListItem key={res.name} disablePadding>
|
<ListItem key={res.name} disablePadding>
|
||||||
<ListItemButton
|
<ListItemButton
|
||||||
selected={selectedResourceName === res.name}
|
selected={selectedResourceName === res.name}
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ interface GenericFormProps {
|
|||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import { ConfigContext } from '../App';
|
||||||
|
|
||||||
export default function GenericForm({
|
export default function GenericForm({
|
||||||
config,
|
config,
|
||||||
initialData = {},
|
initialData = {},
|
||||||
@@ -33,6 +35,7 @@ export default function GenericForm({
|
|||||||
}: GenericFormProps) {
|
}: GenericFormProps) {
|
||||||
const [formData, setFormData] = React.useState(initialData);
|
const [formData, setFormData] = React.useState(initialData);
|
||||||
const { uploadFile, uploading } = useUpload();
|
const { uploadFile, uploading } = useUpload();
|
||||||
|
const appConfig = React.useContext(ConfigContext);
|
||||||
|
|
||||||
const handleChange = (key: string, value: any) => {
|
const handleChange = (key: string, value: any) => {
|
||||||
setFormData((prev: any) => ({ ...prev, [key]: value }));
|
setFormData((prev: any) => ({ ...prev, [key]: value }));
|
||||||
@@ -60,6 +63,7 @@ export default function GenericForm({
|
|||||||
disabled={field.readOnly}
|
disabled={field.readOnly}
|
||||||
uploadFile={uploadFile}
|
uploadFile={uploadFile}
|
||||||
uploading={uploading}
|
uploading={uploading}
|
||||||
|
baseUrl={appConfig?.baseUrl || ""}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
@@ -75,7 +79,7 @@ export default function GenericForm({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function FormField({ name, field, value, onChange, disabled, uploadFile, uploading }: any) {
|
function FormField({ name, field, value, onChange, disabled, uploadFile, uploading, baseUrl }: any) {
|
||||||
const label = field.label;
|
const label = field.label;
|
||||||
|
|
||||||
if (field.type === 'image') {
|
if (field.type === 'image') {
|
||||||
@@ -83,11 +87,12 @@ function FormField({ name, field, value, onChange, disabled, uploadFile, uploadi
|
|||||||
<ImageUploadField
|
<ImageUploadField
|
||||||
label={label}
|
label={label}
|
||||||
value={value}
|
value={value}
|
||||||
onUpload={async (file) => {
|
onUpload={async (file: any) => {
|
||||||
const url = await uploadFile(file);
|
const url = await uploadFile(file);
|
||||||
if (url) onChange(url);
|
if (url) onChange(url);
|
||||||
}}
|
}}
|
||||||
uploading={uploading}
|
uploading={uploading}
|
||||||
|
baseUrl={baseUrl}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import * as React from "react";
|
|
||||||
import { Box, Button, Avatar, CircularProgress, Typography } from "@mui/material";
|
import { Box, Button, Avatar, CircularProgress, Typography } from "@mui/material";
|
||||||
import { config } from "../../config";
|
|
||||||
|
|
||||||
interface ImageUploadFieldProps {
|
interface ImageUploadFieldProps {
|
||||||
label?: string;
|
label?: string;
|
||||||
@@ -8,6 +6,7 @@ interface ImageUploadFieldProps {
|
|||||||
uploading?: boolean;
|
uploading?: boolean;
|
||||||
onUpload: (file: File) => void;
|
onUpload: (file: File) => void;
|
||||||
size?: number;
|
size?: number;
|
||||||
|
baseUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ImageUploadField({
|
export default function ImageUploadField({
|
||||||
@@ -16,10 +15,11 @@ export default function ImageUploadField({
|
|||||||
uploading = false,
|
uploading = false,
|
||||||
onUpload,
|
onUpload,
|
||||||
size = 64,
|
size = 64,
|
||||||
|
baseUrl,
|
||||||
}: ImageUploadFieldProps) {
|
}: ImageUploadFieldProps) {
|
||||||
|
|
||||||
const imgSrc = value
|
const imgSrc = value
|
||||||
? config.baseUrl.replace(/\/+$/, "") +
|
? baseUrl.replace(/\/+$/, "") +
|
||||||
"/" +
|
"/" +
|
||||||
value.replace(/^\/+/, "")
|
value.replace(/^\/+/, "")
|
||||||
: "";
|
: "";
|
||||||
|
|||||||
@@ -1,70 +1,14 @@
|
|||||||
import { AppConfig, ResourceConfig } from "./types/config";
|
import { AppConfig } from "./types/config";
|
||||||
|
import { loadConfigFromOpenApi } from "./utils/openapi_loader";
|
||||||
|
|
||||||
const AccountResource: ResourceConfig = {
|
export async function getAppConfig(): Promise<AppConfig> {
|
||||||
name: "accounts",
|
const baseUrl = import.meta.env.VITE_API_BASE_URL || "http://localhost:8000"
|
||||||
label: "Account",
|
const config = await loadConfigFromOpenApi(baseUrl);
|
||||||
pluralLabel: "Accounts",
|
|
||||||
endpoint: "/accounts",
|
|
||||||
primaryKey: "id",
|
|
||||||
fields: {
|
|
||||||
id: { type: "string", label: "ID", readOnly: true },
|
|
||||||
name: { type: "string", label: "Name", required: true },
|
|
||||||
type: {
|
|
||||||
type: "enum",
|
|
||||||
label: "Type",
|
|
||||||
required: true,
|
|
||||||
options: ["cash", "bank", "credit_card", "wallet", "other"],
|
|
||||||
},
|
|
||||||
currency: { type: "string", label: "Currency", required: true },
|
|
||||||
is_active: { type: "boolean", label: "Active" },
|
|
||||||
avatar: { type: "image", label: "Account Icon" },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const TagResource: ResourceConfig = {
|
// You can still apply overrides here
|
||||||
name: "tags",
|
return {
|
||||||
label: "Tag",
|
...config,
|
||||||
pluralLabel: "Tags",
|
authBaseUrl: import.meta.env.VITE_AUTH_BASE_URL || "http://localhost:8001",
|
||||||
endpoint: "/tags",
|
baseUrl: import.meta.env.VITE_API_BASE_URL || config.baseUrl,
|
||||||
primaryKey: "id",
|
};
|
||||||
fields: {
|
}
|
||||||
id: { type: "string", label: "ID", readOnly: true },
|
|
||||||
name: { type: "string", label: "Name", required: true },
|
|
||||||
parent_id: { type: "string", label: "Parent ID" },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const ExpenseResource: ResourceConfig = {
|
|
||||||
name: "expenses",
|
|
||||||
label: "Expense",
|
|
||||||
pluralLabel: "Expenses",
|
|
||||||
endpoint: "/expenses",
|
|
||||||
primaryKey: "id",
|
|
||||||
fields: {
|
|
||||||
id: { type: "string", label: "ID", readOnly: true },
|
|
||||||
amount: { type: "number", label: "Amount", required: true },
|
|
||||||
occurred_at: { type: "datetime", label: "Occurred At", required: true },
|
|
||||||
payee: {
|
|
||||||
type: "object",
|
|
||||||
label: "Payee",
|
|
||||||
required: true,
|
|
||||||
schema: {
|
|
||||||
name: { type: "string", label: "Name", required: true },
|
|
||||||
type: {
|
|
||||||
type: "enum",
|
|
||||||
label: "Type",
|
|
||||||
options: ["merchant", "person", "transfer", "other"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
account: { type: "string", label: "Account ID", required: true },
|
|
||||||
tags: { type: "array", label: "Tags" },
|
|
||||||
created_at: { type: "datetime", label: "Created At", readOnly: true },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const config: AppConfig = {
|
|
||||||
baseUrl: import.meta.env.VITE_API_BASE_URL || "http://localhost:8000",
|
|
||||||
authBaseUrl: import.meta.env.VITE_AUTH_BASE_URL || "http://localhost:8001",
|
|
||||||
resources: [ExpenseResource, AccountResource, TagResource],
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { Buffer } from 'buffer';
|
||||||
|
import process from 'process';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
|
|
||||||
|
// Polyfill Node.js globals for browser environment (needed by SwaggerParser)
|
||||||
|
window.Buffer = Buffer;
|
||||||
|
window.process = process;
|
||||||
|
|
||||||
const rootElement = document.getElementById('root');
|
const rootElement = document.getElementById('root');
|
||||||
const root = createRoot(rootElement!);
|
const root = createRoot(rootElement!);
|
||||||
|
|
||||||
|
|||||||
117
src_generic/utils/openapi_loader.ts
Normal file
117
src_generic/utils/openapi_loader.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import SwaggerParser from "@apidevtools/swagger-parser";
|
||||||
|
import { AppConfig, ResourceConfig, ResourceField, FieldType } from "../types/config";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps OpenAPI property types to our internal FieldType
|
||||||
|
*/
|
||||||
|
function mapOpenApiType(prop: any): FieldType {
|
||||||
|
const type = prop.type;
|
||||||
|
const format = prop.format;
|
||||||
|
|
||||||
|
if (format === "date-time") return "datetime";
|
||||||
|
if (format === "date") return "date";
|
||||||
|
if (prop.enum) return "enum";
|
||||||
|
if (type === "string" && (prop.description?.toLowerCase().includes("image") || prop.name?.toLowerCase().includes("icon"))) return "image";
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case "integer":
|
||||||
|
case "number": return "number";
|
||||||
|
case "boolean": return "boolean";
|
||||||
|
case "object": return "object";
|
||||||
|
case "array": return "array";
|
||||||
|
default: return "string";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively converts OpenAPI schemas to ResourceField map
|
||||||
|
*/
|
||||||
|
function parseSchemaFields(schema: any): Record<string, ResourceField> {
|
||||||
|
const fields: Record<string, ResourceField> = {};
|
||||||
|
const properties = schema.properties || {};
|
||||||
|
const required = schema.required || [];
|
||||||
|
|
||||||
|
for (const [key, prop] of Object.entries(properties) as any) {
|
||||||
|
fields[key] = {
|
||||||
|
type: mapOpenApiType(prop),
|
||||||
|
label: prop.title || key.charAt(0).toUpperCase() + key.slice(1).replace(/_/g, " "),
|
||||||
|
required: required.includes(key),
|
||||||
|
options: prop.enum,
|
||||||
|
readOnly: prop.readOnly || key === "id" || key === "created_at" || key === "updated_at",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (fields[key].type === "object" && prop.properties) {
|
||||||
|
fields[key].schema = parseSchemaFields(prop);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scans paths to identify resources and their basic configuration
|
||||||
|
*/
|
||||||
|
export async function loadConfigFromOpenApi(baseUrl: string): Promise<AppConfig> {
|
||||||
|
// 1. Parse and dereference the spec (handles all $ref)
|
||||||
|
const api = await SwaggerParser.dereference(
|
||||||
|
new URL("/openapi.json", baseUrl).href
|
||||||
|
);
|
||||||
|
|
||||||
|
const resources: ResourceConfig[] = [];
|
||||||
|
const paths = api.paths || {};
|
||||||
|
|
||||||
|
// Group paths by base resource name (e.g., /expenses, /expenses/{id} -> expenses)
|
||||||
|
const resourcePaths: Record<string, any> = {};
|
||||||
|
|
||||||
|
for (const path of Object.keys(paths)) {
|
||||||
|
const base = path.split("/")[1];
|
||||||
|
if (!base) continue;
|
||||||
|
|
||||||
|
if (!resourcePaths[base]) resourcePaths[base] = { path, methods: [] };
|
||||||
|
const methods = Object.keys(paths[path] || {});
|
||||||
|
resourcePaths[base].methods.push(...methods);
|
||||||
|
|
||||||
|
// We prefer the plural GET path for schema extraction
|
||||||
|
if (!path.includes("{") && paths[path]?.get?.responses?.["200"]) {
|
||||||
|
resourcePaths[base].listPath = path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate ResourceConfig for each identified base path
|
||||||
|
for (const [name, info] of Object.entries(resourcePaths)) {
|
||||||
|
const listPath = info.listPath || `/${name}`;
|
||||||
|
const listOp = paths[listPath]?.get;
|
||||||
|
if (!listOp) continue;
|
||||||
|
|
||||||
|
// Use common naming conventions or metadata from the spec
|
||||||
|
const label = name.charAt(0).toUpperCase() + name.slice(1, -1); // naive singularization
|
||||||
|
const pluralLabel = name.charAt(0).toUpperCase() + name.slice(1);
|
||||||
|
|
||||||
|
// Extract schema from the 200 response of the list endpoint
|
||||||
|
let schema: any = null;
|
||||||
|
const responseSchema = listOp.responses?.["200"]?.content?.["application/json"]?.schema;
|
||||||
|
|
||||||
|
if (responseSchema?.type === "array" && responseSchema.items) {
|
||||||
|
schema = responseSchema.items;
|
||||||
|
} else {
|
||||||
|
schema = responseSchema;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (schema) {
|
||||||
|
resources.push({
|
||||||
|
name,
|
||||||
|
label: schema.title || label,
|
||||||
|
pluralLabel: pluralLabel,
|
||||||
|
endpoint: listPath,
|
||||||
|
primaryKey: "id", // assume 'id' as default or look for 'required' + 'unique'
|
||||||
|
fields: parseSchemaFields(schema),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
baseUrl: import.meta.env.VITE_API_BASE_URL || (api.servers?.[0]?.url ?? ""),
|
||||||
|
authBaseUrl: import.meta.env.VITE_AUTH_BASE_URL || "",
|
||||||
|
resources,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user