mirror of
https://github.com/trailbaseio/trailbase.git
synced 2026-01-09 03:10:17 -06:00
Fix: JSON API schemas are now properly tied to API rather than table with correct expansion config.
This commit is contained in:
@@ -228,21 +228,42 @@ function ConflictResolutionSrategyToString(
|
||||
}
|
||||
}
|
||||
|
||||
export function getRecordApis(
|
||||
config: Config | undefined,
|
||||
tableName: string,
|
||||
): RecordApiConfig[] {
|
||||
return (config?.recordApis ?? []).filter(
|
||||
(api) => api.tableName === tableName,
|
||||
);
|
||||
}
|
||||
|
||||
export function hasRecordApis(
|
||||
config: Config | undefined,
|
||||
tableName: string,
|
||||
): boolean {
|
||||
for (const api of config?.recordApis ?? []) {
|
||||
if (api.tableName === tableName) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function findRecordApi(
|
||||
config: Config | undefined,
|
||||
tableName: string,
|
||||
): RecordApiConfig | undefined {
|
||||
if (!config) {
|
||||
return undefined;
|
||||
}
|
||||
const apis = getRecordApis(config, tableName);
|
||||
|
||||
for (const api of config.recordApis) {
|
||||
if (api.tableName == tableName) {
|
||||
return api;
|
||||
}
|
||||
switch (apis.length) {
|
||||
case 0:
|
||||
return undefined;
|
||||
case 1:
|
||||
return apis[0];
|
||||
default:
|
||||
console.warn("Multiple APIs not yet supported in UI, picking first.");
|
||||
return apis[0];
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function StyledHoverCard(props: { children: JSXElement }) {
|
||||
@@ -298,7 +319,7 @@ export function RecordApiSettingsForm(props: {
|
||||
|
||||
// FIXME: We don't currently handle the "multiple APIs for a single table" case.
|
||||
const currentApi = () =>
|
||||
findRecordApi(config.data!.config!, props.schema.name);
|
||||
findRecordApi(config.data!.config, props.schema.name);
|
||||
|
||||
const form = createForm(() => {
|
||||
const tableName = props.schema.name;
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import { createSignal, createResource, Switch, Match, Show } from "solid-js";
|
||||
import {
|
||||
createMemo,
|
||||
createSignal,
|
||||
createResource,
|
||||
Switch,
|
||||
Match,
|
||||
Show,
|
||||
} from "solid-js";
|
||||
import { createWritableMemo } from "@solid-primitives/memo";
|
||||
import { adminFetch } from "@/lib/fetch";
|
||||
import { TbDownload, TbColumns, TbColumnsOff } from "solid-icons/tb";
|
||||
import { showSaveFileDialog } from "@/lib/utils";
|
||||
import { iconButtonStyle } from "@/components/IconButton";
|
||||
|
||||
import { RecordApiConfig } from "@proto/config";
|
||||
import type { Table } from "@bindings/Table";
|
||||
import type { TableIndex } from "@bindings/TableIndex";
|
||||
import type { TableTrigger } from "@bindings/TableTrigger";
|
||||
@@ -33,7 +42,7 @@ const modes = ["Insert", "Select", "Update"] as const;
|
||||
type Mode = (typeof modes)[number];
|
||||
|
||||
function SchemaDownloadButton(props: {
|
||||
tableName: string;
|
||||
apiName: string;
|
||||
mode: Mode;
|
||||
schema: object;
|
||||
}) {
|
||||
@@ -46,7 +55,7 @@ function SchemaDownloadButton(props: {
|
||||
// possible fallback: https://stackoverflow.com/a/67806663
|
||||
showSaveFileDialog({
|
||||
contents: JSON.stringify(props.schema, null, " "),
|
||||
filename: `${props.tableName}_${props.mode.toLowerCase()}_schema.json`,
|
||||
filename: `${props.apiName}_${props.mode.toLowerCase()}_schema.json`,
|
||||
}).catch(console.error);
|
||||
}}
|
||||
>
|
||||
@@ -55,17 +64,23 @@ function SchemaDownloadButton(props: {
|
||||
);
|
||||
}
|
||||
|
||||
export function SchemaDialog(props: { tableName: string }) {
|
||||
export function SchemaDialog(props: {
|
||||
tableName: string;
|
||||
apis: RecordApiConfig[];
|
||||
}) {
|
||||
const [mode, setMode] = createSignal<Mode>("Select");
|
||||
const apiNames = createMemo(() => props.apis.map((api) => api.name));
|
||||
const [api, setApi] = createWritableMemo(() => apiNames()[0] ?? "");
|
||||
|
||||
const args = () => ({
|
||||
mode: mode(),
|
||||
tableName: props.tableName,
|
||||
apiName: api(),
|
||||
});
|
||||
|
||||
const [schema] = createResource(args, async ({ mode, tableName }) => {
|
||||
console.debug(`Fetching ${tableName}: ${mode}`);
|
||||
const [schema] = createResource(args, async ({ mode, apiName }) => {
|
||||
console.debug(`Fetching ${apiName}: ${mode}`);
|
||||
const response = await adminFetch(
|
||||
`/table/${tableName}/schema.json?mode=${mode}`,
|
||||
`/schema/${apiName}/schema.json?mode=${mode}`,
|
||||
);
|
||||
return await response.json();
|
||||
});
|
||||
@@ -77,7 +92,8 @@ export function SchemaDialog(props: { tableName: string }) {
|
||||
<TooltipTrigger as="div">
|
||||
<TbColumns size={20} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>JSON Schema of "{props.tableName}"</TooltipContent>
|
||||
|
||||
<TooltipContent>JSON Schemas of "{props.tableName}"</TooltipContent>
|
||||
</Tooltip>
|
||||
</DialogTrigger>
|
||||
|
||||
@@ -89,12 +105,31 @@ export function SchemaDialog(props: { tableName: string }) {
|
||||
<div class="flex items-center gap-2">
|
||||
<Show when={schema.state === "ready"}>
|
||||
<SchemaDownloadButton
|
||||
tableName={props.tableName}
|
||||
apiName={api()}
|
||||
schema={schema()}
|
||||
mode={mode()}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<Select
|
||||
value={api()}
|
||||
onChange={setApi}
|
||||
options={[...apiNames()]}
|
||||
placeholder="API"
|
||||
itemComponent={(props) => (
|
||||
<SelectItem item={props.item}>
|
||||
{props.item.rawValue}
|
||||
</SelectItem>
|
||||
)}
|
||||
>
|
||||
<SelectTrigger aria-label="Apis" class="w-[180px]">
|
||||
<SelectValue>
|
||||
{(state) => `API: ${state.selectedOption()}`}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent />
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={mode()}
|
||||
onChange={setMode}
|
||||
@@ -106,9 +141,9 @@ export function SchemaDialog(props: { tableName: string }) {
|
||||
</SelectItem>
|
||||
)}
|
||||
>
|
||||
<SelectTrigger aria-label="Fruit" class="w-[180px]">
|
||||
<SelectValue<string>>
|
||||
{(state) => state.selectedOption()}
|
||||
<SelectTrigger aria-label="Mode" class="w-[180px]">
|
||||
<SelectValue>
|
||||
{(state) => `Mode: ${state.selectedOption()}`}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent />
|
||||
|
||||
@@ -40,7 +40,11 @@ import { FilterBar } from "@/components/FilterBar";
|
||||
import { DestructiveActionButton } from "@/components/DestructiveActionButton";
|
||||
import { IconButton } from "@/components/IconButton";
|
||||
import { InsertUpdateRowForm } from "@/components/tables/InsertUpdateRow";
|
||||
import { RecordApiSettingsForm } from "@/components/tables/RecordApiSettings";
|
||||
import {
|
||||
RecordApiSettingsForm,
|
||||
hasRecordApis,
|
||||
getRecordApis,
|
||||
} from "@/components/tables/RecordApiSettings";
|
||||
import { SafeSheet } from "@/components/SafeSheet";
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -52,7 +56,6 @@ import { createConfigQuery, invalidateConfig } from "@/lib/config";
|
||||
import { type FormRow, RowData } from "@/lib/convert";
|
||||
import { adminFetch } from "@/lib/fetch";
|
||||
import { urlSafeBase64ToUuid } from "@/lib/utils";
|
||||
import { RecordApiConfig } from "@proto/config";
|
||||
import { dropTable, dropIndex } from "@/lib/table";
|
||||
import { deleteRows, fetchRows, type FetchArgs } from "@/lib/row";
|
||||
import {
|
||||
@@ -212,6 +215,21 @@ function imageUrl(opts: {
|
||||
return uri;
|
||||
}
|
||||
|
||||
function tableOrViewSatisfiesRecordApiRequirements(
|
||||
table: Table | View,
|
||||
allTables: Table[],
|
||||
): boolean {
|
||||
const type = tableType(table);
|
||||
|
||||
if (type === "table") {
|
||||
return tableSatisfiesRecordApiRequirements(table as Table, allTables);
|
||||
} else if (type === "view") {
|
||||
return viewSatisfiesRecordApiRequirements(table as View, allTables);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function TableHeaderRightHandButtons(props: {
|
||||
table: Table | View;
|
||||
allTables: Table[];
|
||||
@@ -220,32 +238,12 @@ function TableHeaderRightHandButtons(props: {
|
||||
const table = () => props.table;
|
||||
const hidden = () => hiddenTable(table());
|
||||
const type = () => tableType(table());
|
||||
|
||||
const satisfiesRecordApi = createMemo(() => {
|
||||
const t = type();
|
||||
if (t === "table") {
|
||||
return tableSatisfiesRecordApiRequirements(
|
||||
props.table as Table,
|
||||
props.allTables,
|
||||
);
|
||||
} else if (t === "view") {
|
||||
return viewSatisfiesRecordApiRequirements(
|
||||
props.table as View,
|
||||
props.allTables,
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
const satisfiesRecordApi = createMemo(() =>
|
||||
tableOrViewSatisfiesRecordApiRequirements(props.table, props.allTables),
|
||||
);
|
||||
|
||||
const config = createConfigQuery();
|
||||
const recordApi = (): RecordApiConfig | undefined => {
|
||||
for (const c of config.data?.config?.recordApis ?? []) {
|
||||
if (c.tableName === table().name) {
|
||||
return c;
|
||||
}
|
||||
}
|
||||
};
|
||||
const hasRecordApi = () => hasRecordApis(config?.data?.config, table().name);
|
||||
|
||||
return (
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
@@ -293,7 +291,7 @@ function TableHeaderRightHandButtons(props: {
|
||||
API
|
||||
<Checkbox
|
||||
disabled={!satisfiesRecordApi()}
|
||||
checked={recordApi() !== undefined}
|
||||
checked={hasRecordApi()}
|
||||
/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
@@ -353,6 +351,40 @@ function TableHeaderRightHandButtons(props: {
|
||||
);
|
||||
}
|
||||
|
||||
function TableHeaderLeftButtons(props: {
|
||||
table: Table | View;
|
||||
indexes: TableIndex[];
|
||||
triggers: TableTrigger[];
|
||||
allTables: Table[];
|
||||
rowsRefetch: () => Promise<void>;
|
||||
}) {
|
||||
const type = () => tableType(props.table);
|
||||
const config = createConfigQuery();
|
||||
const apis = createMemo(() =>
|
||||
getRecordApis(config?.data?.config, props.table.name),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton tooltip="Refresh Data" onClick={props.rowsRefetch}>
|
||||
<TbRefresh size={18} />
|
||||
</IconButton>
|
||||
|
||||
{apis().length > 0 && (
|
||||
<SchemaDialog tableName={props.table.name} apis={apis()} />
|
||||
)}
|
||||
|
||||
{import.meta.env.DEV && type() === "table" && (
|
||||
<DebugSchemaDialogButton
|
||||
table={props.table as Table}
|
||||
indexes={props.indexes}
|
||||
triggers={props.triggers}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TableHeader(props: {
|
||||
table: Table | View;
|
||||
indexes: TableIndex[];
|
||||
@@ -361,10 +393,8 @@ function TableHeader(props: {
|
||||
schemaRefetch: () => Promise<void>;
|
||||
rowsRefetch: () => Promise<void>;
|
||||
}) {
|
||||
const type = () => tableType(props.table);
|
||||
const hasSchema = () => type() === "table";
|
||||
const header = () => {
|
||||
switch (type()) {
|
||||
const headerTitle = () => {
|
||||
switch (tableType(props.table)) {
|
||||
case "view":
|
||||
return "View";
|
||||
case "virtualTable":
|
||||
@@ -374,29 +404,19 @@ function TableHeader(props: {
|
||||
}
|
||||
};
|
||||
|
||||
const LeftButtons = () => (
|
||||
<>
|
||||
<IconButton tooltip="Refresh Data" onClick={props.rowsRefetch}>
|
||||
<TbRefresh size={18} />
|
||||
</IconButton>
|
||||
|
||||
{hasSchema() && <SchemaDialog tableName={props.table.name} />}
|
||||
|
||||
{hasSchema() && import.meta.env.DEV && (
|
||||
<DebugSchemaDialogButton
|
||||
table={props.table as Table}
|
||||
indexes={props.indexes}
|
||||
triggers={props.triggers}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Header
|
||||
title={header()}
|
||||
title={headerTitle()}
|
||||
titleSelect={props.table.name}
|
||||
left={<LeftButtons />}
|
||||
left={
|
||||
<TableHeaderLeftButtons
|
||||
table={props.table}
|
||||
indexes={props.indexes}
|
||||
triggers={props.triggers}
|
||||
allTables={props.allTables}
|
||||
rowsRefetch={props.rowsRefetch}
|
||||
/>
|
||||
}
|
||||
right={
|
||||
<TableHeaderRightHandButtons
|
||||
table={props.table}
|
||||
|
||||
@@ -112,7 +112,7 @@ pub struct ServerArgs {
|
||||
#[derive(Args, Clone, Debug)]
|
||||
pub struct JsonSchemaArgs {
|
||||
/// Name of the table to infer the JSON Schema from.
|
||||
pub table: String,
|
||||
pub api: String,
|
||||
|
||||
/// Use-case for the type that determines which columns/fields will be required [Default:
|
||||
/// Insert].
|
||||
|
||||
@@ -10,14 +10,12 @@ use serde::Deserialize;
|
||||
use std::rc::Rc;
|
||||
use tokio::{fs, io::AsyncWriteExt};
|
||||
use trailbase::{
|
||||
api::{self, init_app_state, Email, InitArgs, TokenClaims},
|
||||
api::{self, init_app_state, Email, InitArgs, JsonSchemaMode, TokenClaims},
|
||||
constants::USER_TABLE,
|
||||
DataDir, Server, ServerOptions,
|
||||
};
|
||||
|
||||
use trailbase_cli::{
|
||||
AdminSubCommands, DefaultCommandLineArgs, JsonSchemaModeArg, SubCommands, UserSubCommands,
|
||||
};
|
||||
use trailbase_cli::{AdminSubCommands, DefaultCommandLineArgs, SubCommands, UserSubCommands};
|
||||
|
||||
type BoxError = Box<dyn std::error::Error + Send + Sync>;
|
||||
|
||||
@@ -130,36 +128,18 @@ async fn async_main() -> Result<(), BoxError> {
|
||||
Some(SubCommands::Schema(cmd)) => {
|
||||
init_logger(false);
|
||||
|
||||
let conn = trailbase_sqlite::Connection::new(
|
||||
|| api::connect_sqlite(Some(data_dir.main_db_path()), None),
|
||||
None,
|
||||
)?;
|
||||
let table_metadata = api::TableMetadataCache::new(conn.clone()).await?;
|
||||
let (_new_db, state) =
|
||||
init_app_state(DataDir(args.data_dir), None, InitArgs::default()).await?;
|
||||
|
||||
let table_name = &cmd.table;
|
||||
if let Some(table) = table_metadata.get(table_name) {
|
||||
let (_validator, schema) = trailbase::api::build_json_schema(
|
||||
table.name(),
|
||||
&table.schema.columns,
|
||||
cmd.mode.unwrap_or(JsonSchemaModeArg::Insert).into(),
|
||||
)?;
|
||||
let api_name = &cmd.api;
|
||||
let Some(api) = state.lookup_record_api(api_name) else {
|
||||
return Err(format!("Could not find api: '{api_name}'").into());
|
||||
};
|
||||
|
||||
println!("{}", serde_json::to_string_pretty(&schema)?);
|
||||
} else if let Some(view) = table_metadata.get_view(table_name) {
|
||||
let Some(ref columns) = view.schema.columns else {
|
||||
return Err(format!("Could not derive schema for complex view: '{table_name}'").into());
|
||||
};
|
||||
let mode: Option<JsonSchemaMode> = cmd.mode.map(|m| m.into());
|
||||
let json_schema = trailbase::api::build_api_json_schema(&state, &api, mode)?;
|
||||
|
||||
let (_validator, schema) = trailbase::api::build_json_schema(
|
||||
view.name(),
|
||||
columns,
|
||||
cmd.mode.unwrap_or(JsonSchemaModeArg::Insert).into(),
|
||||
)?;
|
||||
|
||||
println!("{}", serde_json::to_string_pretty(&schema)?);
|
||||
} else {
|
||||
return Err(format!("Could not find table: '{table_name}'").into());
|
||||
}
|
||||
println!("{}", serde_json::to_string_pretty(&json_schema)?);
|
||||
}
|
||||
Some(SubCommands::Migration { suffix }) => {
|
||||
init_logger(false);
|
||||
|
||||
@@ -2,33 +2,30 @@ use axum::extract::{Path, Query, State};
|
||||
use axum::http::header;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use serde::Deserialize;
|
||||
use trailbase_schema::json_schema::JsonSchemaMode;
|
||||
|
||||
use crate::admin::AdminError as Error;
|
||||
use crate::app_state::AppState;
|
||||
|
||||
use trailbase_schema::json_schema::{build_json_schema, JsonSchemaMode};
|
||||
use crate::records::json_schema::build_api_json_schema;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct GetTableSchemaParams {
|
||||
mode: Option<JsonSchemaMode>,
|
||||
}
|
||||
|
||||
pub async fn get_table_schema_handler(
|
||||
pub async fn get_api_json_schema_handler(
|
||||
State(state): State<AppState>,
|
||||
Path(table_name): Path<String>,
|
||||
Path(record_api_name): Path<String>,
|
||||
Query(query): Query<GetTableSchemaParams>,
|
||||
) -> Result<Response, Error> {
|
||||
let Some(table_metadata) = state.table_metadata().get(&table_name) else {
|
||||
return Err(Error::Precondition(format!("Table {table_name} not found")));
|
||||
let Some(api) = state.lookup_record_api(&record_api_name) else {
|
||||
return Err(Error::Precondition(format!(
|
||||
"API {record_api_name} not found"
|
||||
)));
|
||||
};
|
||||
|
||||
// FIXME: With ForeignKey expansion the schema depends on a specific record api and not just a
|
||||
// table schema.
|
||||
let (_schema, json) = build_json_schema(
|
||||
table_metadata.name(),
|
||||
&table_metadata.schema.columns,
|
||||
query.mode.unwrap_or(JsonSchemaMode::Insert),
|
||||
)?;
|
||||
let json =
|
||||
build_api_json_schema(&state, &api, query.mode).map_err(|err| Error::Internal(err.into()))?;
|
||||
|
||||
let mut response = serde_json::to_string_pretty(&json)?.into_response();
|
||||
response.headers_mut().insert(
|
||||
@@ -1,3 +1,7 @@
|
||||
mod get_api_json_schema;
|
||||
|
||||
pub(super) use get_api_json_schema::get_api_json_schema_handler;
|
||||
|
||||
use axum::extract::{Json, State};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ts_rs::TS;
|
||||
@@ -2,13 +2,13 @@ mod config;
|
||||
mod error;
|
||||
mod info;
|
||||
mod jobs;
|
||||
mod json_schema;
|
||||
mod jwt;
|
||||
mod list_logs;
|
||||
mod oauth_providers;
|
||||
mod parse;
|
||||
mod query;
|
||||
pub(crate) mod rows;
|
||||
mod schema;
|
||||
mod table;
|
||||
pub(crate) mod user;
|
||||
|
||||
@@ -37,10 +37,6 @@ pub fn router() -> Router<AppState> {
|
||||
.route("/index", patch(table::alter_index_handler))
|
||||
.route("/index", delete(table::drop_index_handler))
|
||||
// Table actions.
|
||||
.route(
|
||||
"/table/{table_name}/schema.json",
|
||||
get(table::get_table_schema_handler),
|
||||
)
|
||||
.route("/table", post(table::create_table_handler))
|
||||
.route("/table", delete(table::drop_table_handler))
|
||||
.route("/table", patch(table::alter_table_handler))
|
||||
@@ -55,8 +51,12 @@ pub fn router() -> Router<AppState> {
|
||||
.route("/user", patch(user::update_user_handler))
|
||||
.route("/user", delete(user::delete_user_handler))
|
||||
// Schema actions
|
||||
.route("/schema", get(schema::list_schemas_handler))
|
||||
.route("/schema", post(schema::update_schema_handler))
|
||||
.route("/schema", get(json_schema::list_schemas_handler))
|
||||
.route("/schema", post(json_schema::update_schema_handler))
|
||||
.route(
|
||||
"/schema/{record_api_name}/schema.json",
|
||||
get(json_schema::get_api_json_schema_handler),
|
||||
)
|
||||
// Logs
|
||||
.route("/logs", get(list_logs::list_logs_handler))
|
||||
// Query execution handler for the UI editor
|
||||
|
||||
@@ -2,12 +2,10 @@
|
||||
mod alter_index;
|
||||
mod create_index;
|
||||
mod drop_index;
|
||||
mod get_table_schema;
|
||||
|
||||
pub(super) use alter_index::alter_index_handler;
|
||||
pub(super) use create_index::create_index_handler;
|
||||
pub(super) use drop_index::drop_index_handler;
|
||||
pub(super) use get_table_schema::get_table_schema_handler;
|
||||
|
||||
// Tables
|
||||
mod alter_table;
|
||||
|
||||
@@ -236,7 +236,7 @@ impl AppState {
|
||||
return &self.state.jwt;
|
||||
}
|
||||
|
||||
pub(crate) fn lookup_record_api(&self, name: &str) -> Option<RecordApi> {
|
||||
pub fn lookup_record_api(&self, name: &str) -> Option<RecordApi> {
|
||||
for (record_api_name, record_api) in self.state.record_apis.load().iter() {
|
||||
if record_api_name == name {
|
||||
return Some(record_api.clone());
|
||||
|
||||
@@ -64,10 +64,11 @@ pub mod api {
|
||||
pub use crate::connection::connect_sqlite;
|
||||
pub use crate::email::{Email, EmailError};
|
||||
pub use crate::migrations::new_unique_migration_filename;
|
||||
pub use crate::records::json_schema::build_api_json_schema;
|
||||
pub use crate::server::{init_app_state, InitArgs};
|
||||
pub use crate::table_metadata::TableMetadataCache;
|
||||
|
||||
pub use trailbase_schema::json_schema::{build_json_schema, JsonSchemaMode};
|
||||
pub use trailbase_schema::json_schema::JsonSchemaMode;
|
||||
}
|
||||
|
||||
pub(crate) mod rand {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use axum::extract::{Json, Path, Query, State};
|
||||
use log::*;
|
||||
use serde::Deserialize;
|
||||
use trailbase_schema::json_schema::{
|
||||
build_json_schema, build_json_schema_expanded, Expand, JsonSchemaMode,
|
||||
@@ -6,7 +7,7 @@ use trailbase_schema::json_schema::{
|
||||
|
||||
use crate::app_state::AppState;
|
||||
use crate::auth::user::User;
|
||||
use crate::records::{Permission, RecordError};
|
||||
use crate::records::{Permission, RecordApi, RecordError};
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct JsonSchemaQuery {
|
||||
@@ -35,25 +36,34 @@ pub async fn json_schema_handler(
|
||||
.check_record_level_access(Permission::Schema, None, None, user.as_ref())
|
||||
.await?;
|
||||
|
||||
let mode = request.mode.unwrap_or(JsonSchemaMode::Insert);
|
||||
|
||||
match (api.expand(), mode) {
|
||||
(Some(config_expand), JsonSchemaMode::Select) => {
|
||||
let foreign_key_columns = config_expand.keys().map(|k| k.as_str()).collect::<Vec<_>>();
|
||||
let expand = Expand {
|
||||
tables: &state.table_metadata().tables(),
|
||||
foreign_key_columns,
|
||||
};
|
||||
|
||||
let (_schema, json) =
|
||||
build_json_schema_expanded(api.table_name(), api.columns(), mode, Some(expand))
|
||||
.map_err(|err| RecordError::Internal(err.into()))?;
|
||||
return Ok(Json(json));
|
||||
}
|
||||
_ => {
|
||||
let (_schema, json) = build_json_schema(api.table_name(), api.columns(), mode)
|
||||
.map_err(|err| RecordError::Internal(err.into()))?;
|
||||
return Ok(Json(json));
|
||||
}
|
||||
}
|
||||
return Ok(Json(build_api_json_schema(&state, &api, request.mode)?));
|
||||
}
|
||||
|
||||
pub fn build_api_json_schema(
|
||||
state: &AppState,
|
||||
api: &RecordApi,
|
||||
mode: Option<JsonSchemaMode>,
|
||||
) -> Result<serde_json::Value, RecordError> {
|
||||
let mode = mode.unwrap_or(JsonSchemaMode::Insert);
|
||||
|
||||
if let (Some(config_expand), JsonSchemaMode::Select) = (api.expand(), mode) {
|
||||
let all_tables = state.table_metadata().tables();
|
||||
let foreign_key_columns = config_expand.keys().map(|k| k.as_str()).collect::<Vec<_>>();
|
||||
let expand = Expand {
|
||||
tables: &all_tables,
|
||||
foreign_key_columns,
|
||||
};
|
||||
|
||||
debug!("expanded: {expand:?}");
|
||||
|
||||
let (_schema, json) =
|
||||
build_json_schema_expanded(api.api_name(), api.columns(), mode, Some(expand))
|
||||
.map_err(|err| RecordError::Internal(err.into()))?;
|
||||
return Ok(json);
|
||||
}
|
||||
|
||||
let (_schema, json) = build_json_schema(api.api_name(), api.columns(), mode)
|
||||
.map_err(|err| RecordError::Internal(err.into()))?;
|
||||
|
||||
return Ok(json);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ pub(crate) mod create_record;
|
||||
pub(crate) mod delete_record;
|
||||
mod error;
|
||||
pub(crate) mod files;
|
||||
mod json_schema;
|
||||
pub(crate) mod json_schema;
|
||||
pub(crate) mod list_records;
|
||||
pub(crate) mod params;
|
||||
pub mod query_builder;
|
||||
|
||||
@@ -315,11 +315,11 @@ mod tests {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "type": "integer" },
|
||||
"fk": { "$ref": "#/$defs/fk" },
|
||||
"fk": { "$ref": "#/$defs/foreign_table" },
|
||||
},
|
||||
"required": ["id"],
|
||||
"$defs": {
|
||||
"fk": {
|
||||
"foreign_table": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id" : { "type": "integer"},
|
||||
|
||||
@@ -27,13 +27,14 @@ pub enum JsonSchemaMode {
|
||||
/// setting. Not sure we should since this is more a feature for no-JS, HTTP-only apps, which
|
||||
/// don't benefit from type-safety anyway.
|
||||
pub fn build_json_schema(
|
||||
table_or_view_name: &str,
|
||||
title: &str,
|
||||
columns: &[Column],
|
||||
mode: JsonSchemaMode,
|
||||
) -> Result<(Validator, serde_json::Value), JsonSchemaError> {
|
||||
return build_json_schema_expanded(table_or_view_name, columns, mode, None);
|
||||
return build_json_schema_expanded(title, columns, mode, None);
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Expand<'a> {
|
||||
pub tables: &'a [TableMetadata],
|
||||
pub foreign_key_columns: Vec<&'a str>,
|
||||
@@ -42,7 +43,7 @@ pub struct Expand<'a> {
|
||||
/// NOTE: Foreign keys can only reference tables not view, so the inline schemas don't need to be
|
||||
/// able to reference views.
|
||||
pub fn build_json_schema_expanded(
|
||||
table_or_view_name: &str,
|
||||
title: &str,
|
||||
columns: &[Column],
|
||||
mode: JsonSchemaMode,
|
||||
expand: Option<Expand<'_>>,
|
||||
@@ -52,7 +53,7 @@ pub fn build_json_schema_expanded(
|
||||
let mut required_cols: Vec<String> = vec![];
|
||||
|
||||
for col in columns {
|
||||
let mut found_def = false;
|
||||
let mut def_name: Option<String> = None;
|
||||
let mut not_null = false;
|
||||
let mut default = false;
|
||||
|
||||
@@ -62,17 +63,18 @@ pub fn build_json_schema_expanded(
|
||||
ColumnOption::Default(_) => default = true,
|
||||
ColumnOption::Check(check) => {
|
||||
if let Some(json_metadata) = extract_json_metadata(&ColumnOption::Check(check.clone()))? {
|
||||
let new_def_name = &col.name;
|
||||
match json_metadata {
|
||||
JsonColumnMetadata::SchemaName(name) => {
|
||||
let Some(schema) = crate::registry::get_schema(&name) else {
|
||||
return Err(JsonSchemaError::NotFound(name.to_string()));
|
||||
};
|
||||
defs.insert(col.name.clone(), schema.schema);
|
||||
found_def = true;
|
||||
defs.insert(new_def_name.clone(), schema.schema);
|
||||
def_name = Some(new_def_name.clone());
|
||||
}
|
||||
JsonColumnMetadata::Pattern(pattern) => {
|
||||
defs.insert(col.name.clone(), pattern.clone());
|
||||
found_def = true;
|
||||
defs.insert(new_def_name.clone(), pattern.clone());
|
||||
def_name = Some(new_def_name.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -98,49 +100,54 @@ pub fn build_json_schema_expanded(
|
||||
..
|
||||
} => {
|
||||
if let (Some(expand), JsonSchemaMode::Select) = (&expand, mode) {
|
||||
for metadata in &expand.foreign_key_columns {
|
||||
if metadata != foreign_table {
|
||||
continue;
|
||||
}
|
||||
let column_is_expanded = expand
|
||||
.foreign_key_columns
|
||||
.iter()
|
||||
.any(|column_name| *column_name != col.name);
|
||||
if !column_is_expanded {
|
||||
continue;
|
||||
}
|
||||
|
||||
if referred_columns.len() != 1 {
|
||||
warn!("Skipping. Expected single reffered column : {referred_columns:?}");
|
||||
continue;
|
||||
}
|
||||
let Some(table) = expand.tables.iter().find(|t| t.name() == foreign_table) else {
|
||||
warn!("Failed to find table: {foreign_table}");
|
||||
continue;
|
||||
};
|
||||
|
||||
// TODO: Implement nesting.
|
||||
let Some(table) = expand.tables.iter().find(|t| t.name() == foreign_table) else {
|
||||
warn!("Failed to find table: {foreign_table}");
|
||||
continue;
|
||||
};
|
||||
|
||||
let Some(column) = table
|
||||
let Some(pk_column) = (match referred_columns.len() {
|
||||
0 => crate::metadata::find_pk_column_index(&table.schema.columns)
|
||||
.map(|idx| &table.schema.columns[idx]),
|
||||
1 => table
|
||||
.schema
|
||||
.columns
|
||||
.iter()
|
||||
.find(|c| c.name == referred_columns[0])
|
||||
else {
|
||||
warn!("Failed to find column: {}", referred_columns[0]);
|
||||
.find(|c| c.name == referred_columns[0]),
|
||||
_ => {
|
||||
warn!("Skipping. Expected single referred column : {referred_columns:?}");
|
||||
continue;
|
||||
};
|
||||
}
|
||||
}) else {
|
||||
warn!("Failed to find pk column for {}", table.name());
|
||||
continue;
|
||||
};
|
||||
|
||||
let (_validator, schema) =
|
||||
build_json_schema(foreign_table, &table.schema.columns, mode)?;
|
||||
defs.insert(
|
||||
col.name.clone(),
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": column_data_type_to_json_type(column.data_type),
|
||||
},
|
||||
"data": schema,
|
||||
let (_validator, schema) =
|
||||
build_json_schema(foreign_table, &table.schema.columns, mode)?;
|
||||
|
||||
let new_def_name = foreign_table.clone();
|
||||
defs.insert(
|
||||
new_def_name.clone(),
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": column_data_type_to_json_type(pk_column.data_type),
|
||||
},
|
||||
"required": ["id"],
|
||||
}),
|
||||
);
|
||||
found_def = true;
|
||||
}
|
||||
"data": schema,
|
||||
},
|
||||
"required": ["id"],
|
||||
}),
|
||||
);
|
||||
def_name = Some(new_def_name);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
@@ -161,34 +168,30 @@ pub fn build_json_schema_expanded(
|
||||
JsonSchemaMode::Update => {}
|
||||
}
|
||||
|
||||
if found_def {
|
||||
let name = &col.name;
|
||||
properties.insert(
|
||||
name.clone(),
|
||||
properties.insert(
|
||||
col.name.clone(),
|
||||
if let Some(def_name) = def_name {
|
||||
serde_json::json!({
|
||||
"$ref": format!("#/$defs/{name}")
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
properties.insert(
|
||||
col.name.clone(),
|
||||
"$ref": format!("#/$defs/{def_name}")
|
||||
})
|
||||
} else {
|
||||
serde_json::json!({
|
||||
"type": column_data_type_to_json_type(col.data_type),
|
||||
}),
|
||||
);
|
||||
}
|
||||
})
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
let schema = if defs.is_empty() {
|
||||
serde_json::json!({
|
||||
"title": table_or_view_name,
|
||||
"title": title,
|
||||
"type": "object",
|
||||
"properties": serde_json::Value::Object(properties),
|
||||
"required": serde_json::json!(required_cols),
|
||||
})
|
||||
} else {
|
||||
serde_json::json!({
|
||||
"title": table_or_view_name,
|
||||
"title": title,
|
||||
"type": "object",
|
||||
"properties": serde_json::Value::Object(properties),
|
||||
"required": serde_json::json!(required_cols),
|
||||
|
||||
@@ -348,11 +348,8 @@ pub fn find_user_id_foreign_key_columns(columns: &[Column], user_table_name: &st
|
||||
return indexes;
|
||||
}
|
||||
|
||||
/// Finds suitable Integer or UUIDv7 primary key columns, if present.
|
||||
///
|
||||
/// Cursors require certain properties like a stable, time-sortable primary key.
|
||||
fn find_record_pk_column_index(columns: &[Column], tables: &[Table]) -> Option<usize> {
|
||||
let primary_key_col_index = columns.iter().position(|col| {
|
||||
pub(crate) fn find_pk_column_index(columns: &[Column]) -> Option<usize> {
|
||||
return columns.iter().position(|col| {
|
||||
for opt in &col.options {
|
||||
if let ColumnOption::Unique { is_primary, .. } = opt {
|
||||
return *is_primary;
|
||||
@@ -360,67 +357,71 @@ fn find_record_pk_column_index(columns: &[Column], tables: &[Table]) -> Option<u
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(index) = primary_key_col_index {
|
||||
let column = &columns[index];
|
||||
/// Finds suitable Integer or UUIDv7 primary key columns, if present.
|
||||
///
|
||||
/// Cursors require certain properties like a stable, time-sortable primary key.
|
||||
fn find_record_pk_column_index(columns: &[Column], tables: &[Table]) -> Option<usize> {
|
||||
let index = find_pk_column_index(columns)?;
|
||||
let column = &columns[index];
|
||||
|
||||
if column.data_type == ColumnDataType::Integer {
|
||||
// TODO: We should detect the "integer pk" desc case and at least warn:
|
||||
// https://www.sqlite.org/lang_createtable.html#rowid.
|
||||
return Some(index);
|
||||
if column.data_type == ColumnDataType::Integer {
|
||||
// TODO: We should detect the "integer pk" desc case and at least warn:
|
||||
// https://www.sqlite.org/lang_createtable.html#rowid.
|
||||
return Some(index);
|
||||
}
|
||||
|
||||
for opts in &column.options {
|
||||
lazy_static! {
|
||||
static ref UUID_V7_RE: Regex = Regex::new(r"^is_uuid_v7\s*\(").expect("infallible");
|
||||
}
|
||||
|
||||
for opts in &column.options {
|
||||
lazy_static! {
|
||||
static ref UUID_V7_RE: Regex = Regex::new(r"^is_uuid_v7\s*\(").expect("infallible");
|
||||
}
|
||||
|
||||
match &opts {
|
||||
// Check if the referenced column is a uuidv7 column.
|
||||
ColumnOption::ForeignKey {
|
||||
foreign_table,
|
||||
referred_columns,
|
||||
..
|
||||
} => {
|
||||
let Some(referred_table) = tables.iter().find(|t| t.name == *foreign_table) else {
|
||||
error!("Failed to get foreign key schema for {foreign_table}");
|
||||
continue;
|
||||
};
|
||||
|
||||
if referred_columns.len() != 1 {
|
||||
return None;
|
||||
}
|
||||
let referred_column = &referred_columns[0];
|
||||
|
||||
let col = referred_table
|
||||
.columns
|
||||
.iter()
|
||||
.find(|c| c.name == *referred_column)?;
|
||||
|
||||
let mut is_pk = false;
|
||||
for opt in &col.options {
|
||||
match opt {
|
||||
ColumnOption::Check(expr) if UUID_V7_RE.is_match(expr) => {
|
||||
return Some(index);
|
||||
}
|
||||
ColumnOption::Unique { is_primary, .. } if *is_primary => {
|
||||
is_pk = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if is_pk && col.data_type == ColumnDataType::Integer {
|
||||
return Some(index);
|
||||
}
|
||||
match &opts {
|
||||
// Check if the referenced column is a uuidv7 column.
|
||||
ColumnOption::ForeignKey {
|
||||
foreign_table,
|
||||
referred_columns,
|
||||
..
|
||||
} => {
|
||||
let Some(referred_table) = tables.iter().find(|t| t.name == *foreign_table) else {
|
||||
error!("Failed to get foreign key schema for {foreign_table}");
|
||||
continue;
|
||||
};
|
||||
|
||||
if referred_columns.len() != 1 {
|
||||
return None;
|
||||
}
|
||||
ColumnOption::Check(expr) if UUID_V7_RE.is_match(expr) => {
|
||||
let referred_column = &referred_columns[0];
|
||||
|
||||
let col = referred_table
|
||||
.columns
|
||||
.iter()
|
||||
.find(|c| c.name == *referred_column)?;
|
||||
|
||||
let mut is_pk = false;
|
||||
for opt in &col.options {
|
||||
match opt {
|
||||
ColumnOption::Check(expr) if UUID_V7_RE.is_match(expr) => {
|
||||
return Some(index);
|
||||
}
|
||||
ColumnOption::Unique { is_primary, .. } if *is_primary => {
|
||||
is_pk = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if is_pk && col.data_type == ColumnDataType::Integer {
|
||||
return Some(index);
|
||||
}
|
||||
_ => {}
|
||||
|
||||
return None;
|
||||
}
|
||||
ColumnOption::Check(expr) if UUID_V7_RE.is_match(expr) => {
|
||||
return Some(index);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user