reading from openapi spec

This commit is contained in:
2026-04-01 18:06:09 +05:30
parent 3b472242a7
commit 344106f1a4
9 changed files with 265 additions and 103 deletions

View File

@@ -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>
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))', gap: 3, mt: 4 }}> <Typography variant="body1">
{config.resources.map(res => ( Select a resource from the sidebar to manage data.
<Paper key={res.name} sx={{ p: 3, textAlign: 'center' }}> </Typography>
<Box
sx={{
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,32 +46,37 @@ 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
username={currentUser.username} username={currentUser.username}
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>
); );

View File

@@ -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);
}

View File

@@ -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}

View File

@@ -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}
/> />
); );
} }

View File

@@ -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(/^\/+/, "")
: ""; : "";

View File

@@ -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],
};

View File

@@ -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!);

View 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,
};
}