reading from openapi spec
This commit is contained in:
@@ -1,24 +1,41 @@
|
||||
import * as React from 'react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import * as React from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { AuthProvider, useAuth, AuthPage } from "../auth/src";
|
||||
import { UploadProvider } from "./providers/UploadProvider";
|
||||
import AdminLayout from "./components/AdminLayout";
|
||||
import ResourceView from "./components/ResourceView";
|
||||
import { config } from "./config";
|
||||
import { Box, Typography, Paper } from '@mui/material';
|
||||
import { getAppConfig } from "./config";
|
||||
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";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
// Create a context for the app config
|
||||
export const ConfigContext = React.createContext<AppConfig | null>(null);
|
||||
|
||||
function Dashboard() {
|
||||
const config = React.useContext(ConfigContext);
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h4" gutterBottom>Welcome to the Admin Panel</Typography>
|
||||
<Typography variant="body1">Select a resource from the sidebar to manage data.</Typography>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
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 }}>
|
||||
{config.resources.map(res => (
|
||||
<Paper key={res.name} sx={{ p: 3, textAlign: 'center' }}>
|
||||
<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>
|
||||
</Paper>
|
||||
))}
|
||||
@@ -29,25 +46,29 @@ function Dashboard() {
|
||||
|
||||
function AdminApp() {
|
||||
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) {
|
||||
return (
|
||||
<AuthPage
|
||||
mode="login"
|
||||
login={login}
|
||||
register={async () => {
|
||||
}} // Disable registration for Admin
|
||||
register={async () => {}} // Disable registration for Admin
|
||||
loading={loading}
|
||||
error={error}
|
||||
onSwitchMode={() => {
|
||||
}} onBack={function (): void {
|
||||
throw new Error("Function not implemented.");
|
||||
}} currentUser={undefined} />
|
||||
onSwitchMode={() => {}}
|
||||
onBack={() => {}}
|
||||
currentUser={null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const selectedResource = config.resources.find(r => r.name === selectedResourceName);
|
||||
const selectedResource = config?.resources.find(
|
||||
(r) => r.name === selectedResourceName
|
||||
);
|
||||
|
||||
return (
|
||||
<AdminLayout
|
||||
@@ -55,6 +76,7 @@ function AdminApp() {
|
||||
onLogout={logout}
|
||||
selectedResourceName={selectedResourceName}
|
||||
onSelectResource={setSelectedResourceName}
|
||||
resources={config?.resources || []}
|
||||
>
|
||||
{selectedResource ? (
|
||||
<ResourceView key={selectedResource.name} config={selectedResource} />
|
||||
@@ -66,14 +88,42 @@ function AdminApp() {
|
||||
}
|
||||
|
||||
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 (
|
||||
<AppTheme>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ConfigContext.Provider value={config}>
|
||||
<AuthProvider authBaseUrl={config.authBaseUrl}>
|
||||
<UploadProvider>
|
||||
<AdminApp />
|
||||
</UploadProvider>
|
||||
</AuthProvider>
|
||||
</ConfigContext.Provider>
|
||||
</QueryClientProvider>
|
||||
</AppTheme>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,43 @@
|
||||
import axios, { AxiosInstance } from "axios";
|
||||
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 DashboardIcon from '@mui/icons-material/Dashboard';
|
||||
import LogoutIcon from '@mui/icons-material/Logout';
|
||||
import { config } from '../config';
|
||||
import { ResourceConfig } from '../types/config';
|
||||
|
||||
const drawerWidth = 240;
|
||||
|
||||
@@ -27,6 +27,7 @@ interface AdminLayoutProps {
|
||||
selectedResourceName: string | null;
|
||||
onLogout: () => void;
|
||||
username?: string;
|
||||
resources: ResourceConfig[];
|
||||
}
|
||||
|
||||
export default function AdminLayout({
|
||||
@@ -34,7 +35,8 @@ export default function AdminLayout({
|
||||
onSelectResource,
|
||||
selectedResourceName,
|
||||
onLogout,
|
||||
username
|
||||
username,
|
||||
resources,
|
||||
}: AdminLayoutProps) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex' }}>
|
||||
@@ -77,7 +79,7 @@ export default function AdminLayout({
|
||||
</List>
|
||||
<Divider />
|
||||
<List>
|
||||
{config.resources.map((res) => (
|
||||
{resources.map((res) => (
|
||||
<ListItem key={res.name} disablePadding>
|
||||
<ListItemButton
|
||||
selected={selectedResourceName === res.name}
|
||||
|
||||
@@ -24,6 +24,8 @@ interface GenericFormProps {
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
import { ConfigContext } from '../App';
|
||||
|
||||
export default function GenericForm({
|
||||
config,
|
||||
initialData = {},
|
||||
@@ -33,6 +35,7 @@ export default function GenericForm({
|
||||
}: GenericFormProps) {
|
||||
const [formData, setFormData] = React.useState(initialData);
|
||||
const { uploadFile, uploading } = useUpload();
|
||||
const appConfig = React.useContext(ConfigContext);
|
||||
|
||||
const handleChange = (key: string, value: any) => {
|
||||
setFormData((prev: any) => ({ ...prev, [key]: value }));
|
||||
@@ -60,6 +63,7 @@ export default function GenericForm({
|
||||
disabled={field.readOnly}
|
||||
uploadFile={uploadFile}
|
||||
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;
|
||||
|
||||
if (field.type === 'image') {
|
||||
@@ -83,11 +87,12 @@ function FormField({ name, field, value, onChange, disabled, uploadFile, uploadi
|
||||
<ImageUploadField
|
||||
label={label}
|
||||
value={value}
|
||||
onUpload={async (file) => {
|
||||
onUpload={async (file: any) => {
|
||||
const url = await uploadFile(file);
|
||||
if (url) onChange(url);
|
||||
}}
|
||||
uploading={uploading}
|
||||
baseUrl={baseUrl}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import * as React from "react";
|
||||
import { Box, Button, Avatar, CircularProgress, Typography } from "@mui/material";
|
||||
import { config } from "../../config";
|
||||
|
||||
interface ImageUploadFieldProps {
|
||||
label?: string;
|
||||
@@ -8,6 +6,7 @@ interface ImageUploadFieldProps {
|
||||
uploading?: boolean;
|
||||
onUpload: (file: File) => void;
|
||||
size?: number;
|
||||
baseUrl: string;
|
||||
}
|
||||
|
||||
export default function ImageUploadField({
|
||||
@@ -16,10 +15,11 @@ export default function ImageUploadField({
|
||||
uploading = false,
|
||||
onUpload,
|
||||
size = 64,
|
||||
baseUrl,
|
||||
}: ImageUploadFieldProps) {
|
||||
|
||||
const imgSrc = value
|
||||
? config.baseUrl.replace(/\/+$/, "") +
|
||||
? baseUrl.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 = {
|
||||
name: "accounts",
|
||||
label: "Account",
|
||||
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" },
|
||||
},
|
||||
};
|
||||
export async function getAppConfig(): Promise<AppConfig> {
|
||||
const baseUrl = import.meta.env.VITE_API_BASE_URL || "http://localhost:8000"
|
||||
const config = await loadConfigFromOpenApi(baseUrl);
|
||||
|
||||
const TagResource: ResourceConfig = {
|
||||
name: "tags",
|
||||
label: "Tag",
|
||||
pluralLabel: "Tags",
|
||||
endpoint: "/tags",
|
||||
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",
|
||||
// You can still apply overrides here
|
||||
return {
|
||||
...config,
|
||||
authBaseUrl: import.meta.env.VITE_AUTH_BASE_URL || "http://localhost:8001",
|
||||
resources: [ExpenseResource, AccountResource, TagResource],
|
||||
};
|
||||
baseUrl: import.meta.env.VITE_API_BASE_URL || config.baseUrl,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import * as React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { Buffer } from 'buffer';
|
||||
import process from 'process';
|
||||
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 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