Fix API routing in admin dashboard, simplify JS/TS client APIs and publish v0.4.0.

This commit is contained in:
Sebastian Jeltsch
2025-05-10 11:05:43 +02:00
parent cf47a9c77a
commit c724c9eaab
11 changed files with 82 additions and 86 deletions

View File

@@ -9,7 +9,7 @@ import { list } from "../src/list.ts";
import { subscribe, subscribeAll } from "../src/subscribe.ts";
async function connect(): Promise<Client> {
const client = new Client("http://localhost:4000");
const client = Client.init("http://localhost:4000");
await client.login("admin@localhost", "secret");
return client;
}

View File

@@ -21,7 +21,7 @@
"nanostores": "^1.0.1",
"solid-icons": "^1.1.0",
"solid-js": "^1.9.6",
"trailbase": "^0.3.3"
"trailbase": "^0.4.1"
},
"devDependencies": {
"@astrojs/solid-js": "^5.0.10",

View File

@@ -39,7 +39,6 @@ function UserBadge(props: { user: User | undefined }) {
}
export function AuthButton() {
const client = useStore($client);
const user = useStore($user);
return (
@@ -47,9 +46,9 @@ export function AuthButton() {
<Match when={!user()}>
<a
href={
client()?.loginUri(
import.meta.env.DEV ? window.location.origin : undefined,
) ?? `/_/auth/login`
import.meta.env.DEV
? "http://localhost:4000/_auth/login?redirect_to=/"
: "/_/auth/login?redirect_to=/"
}
>
Log in
@@ -59,9 +58,13 @@ export function AuthButton() {
<Match when={user()}>
<button
onClick={() => {
// Remove local tokens before redirecting.
removeTokens();
const logout = client()?.logoutUri("/") ?? "/_/auth/logout";
window.location.assign(logout);
const path = import.meta.env.DEV
? "http://localhost:4000/_/auth/logout"
: "/_/auth/logout";
window.location.assign(path);
}}
>
<UserBadge user={user()} />

View File

@@ -1,10 +1,15 @@
import { filePath } from "trailbase";
import { $client } from "@/lib/client";
import type { Article } from "@schema/article";
export function imageUrl(article: Article): string {
const client = $client.get();
if (client && article.image) {
return client.records("articles_view").imageUri(`${article.id}`, "image");
const path = filePath("articles_view", article.id, "image");
return import.meta.env.DEV
? new URL(path, "http://localhost:4000").toString()
: path;
}
return "/default.svg";
}

View File

@@ -30,7 +30,7 @@ export function App({ initialCount }: { initialCount?: number }) {
const sleep = (ms: number) => new Promise(r => setTimeout(r, ms));
const listen = async () => {
const client = new trailbase.Client(window.location.origin);
const client = trailbase.Client.init(window.location.origin);
const api = client.records("counter");
const reader = (await api.subscribe(1)).getReader();

10
pnpm-lock.yaml generated
View File

@@ -155,8 +155,8 @@ importers:
specifier: ^1.9.6
version: 1.9.6
trailbase:
specifier: ^0.3.3
version: 0.3.3
specifier: ^0.4.1
version: 0.4.1
devDependencies:
'@astrojs/solid-js':
specifier: ^5.0.10
@@ -5592,8 +5592,8 @@ packages:
resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==}
engines: {node: '>=18'}
trailbase@0.3.3:
resolution: {integrity: sha512-s0GgYMlMbWJKwg3jcu7d9T5AN3rzBlrIeUpOiQhi8ogNX1BoHzRYb0vq3Yy52nEyybCmY+ExnJQvawzm/Yvc0A==}
trailbase@0.4.1:
resolution: {integrity: sha512-hl4/0DeMwyBmhNJL7NeBFAJhxOHtFHP4oEXVMMY52VSM6YVTn5r3bi5vIG4RTVF14nP2cIheINKwZ19YqiCc5g==}
trim-lines@3.0.1:
resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==}
@@ -12537,7 +12537,7 @@ snapshots:
dependencies:
punycode: 2.3.1
trailbase@0.3.3:
trailbase@0.4.1:
dependencies:
jwt-decode: 4.0.0
uuid: 11.1.0

View File

@@ -11,7 +11,11 @@ const $tokens = persistentAtom<Tokens | null>("auth_tokens", null, {
export const $user = computed($tokens, (_tokens) => client.user());
function initClient(): Client {
const HOST = import.meta.env.DEV ? "http://localhost:4000" : "";
// For our dev server setup we assume that a TrailBase instance is running at ":4000", otherwise
// we query APIs relative to the origin's root path.
const HOST = import.meta.env.DEV
? new URL("http://localhost:4000")
: undefined;
const client = Client.init(HOST, {
tokens: $tokens.get() ?? undefined,
onAuthChange: (c: Client, _user: User | undefined) => {
@@ -39,7 +43,7 @@ export async function adminFetch(
}
try {
return await client.fetch(`api/_admin${input}`, init);
return await client.fetch(`/api/_admin${input}`, init);
} catch (err) {
showToast({
title: "Fetch Error",

View File

@@ -22,7 +22,6 @@ export function buildListSearchParams({
?.split(/AND/)
.map((frag: string) => frag.trim())
.join("&");
console.log("PARAMS", filterParams);
const params = new URLSearchParams(filterParams);
params.set("limit", pageSize.toString());

View File

@@ -28,7 +28,7 @@ export async function createIndex(
export async function createTable(
request: CreateTableRequest,
): Promise<CreateTableResponse> {
const response = await adminFetch("/table", {
const response = await adminFetch("/tableffo", {
method: "POST",
body: JSON.stringify(request),
});

View File

@@ -1,18 +1,18 @@
{
"name": "trailbase",
"description": "Official TrailBase Client",
"version": "0.3.3",
"version": "0.4.1",
"license": "OSL-3.0",
"type": "module",
"main": "./src/index.ts",
"publishConfig": {
"access": "public",
"main": "./dist/js/client/src/index.js",
"types": "/dist/js/client/src/index.d.ts",
"main": "./dist/trailbase-assets/js/client/src/index.js",
"types": "/dist/trailbase-assets/js/client/src/index.d.ts",
"exports": {
".": {
"default": "./dist/js/client/src/index.js",
"types": "./dist/js/client/src/index.d.ts"
"default": "./dist/trailbase-assets/js/client/src/index.js",
"types": "./dist/trailbase-assets/js/client/src/index.d.ts"
}
}
},
@@ -31,6 +31,7 @@
"build": "tsc",
"test": "vitest run && vite-node tests/integration_test_runner.ts",
"format": "prettier -w src tests",
"run-publish": "rm -rf ./dist && pnpm build && pnpm publish . --no-git-checks",
"check": "tsc --noEmit --skipLibCheck && eslint"
},
"devDependencies": {

View File

@@ -143,14 +143,13 @@ export interface FileUpload {
///
/// TODO: add file upload/download.
export class RecordApi {
private static readonly _recordApi = "api/records/v1";
private readonly _createApi: string;
private readonly _path: string;
constructor(
private readonly client: Client,
private readonly name: string,
) {
this._createApi = `${RecordApi._recordApi}/${this.name}`;
this._path = `${recordApiBasePath}/${this.name}`;
}
public async list<T = Record<string, unknown>>(opts?: {
@@ -193,9 +192,7 @@ export class RecordApi {
}
}
const response = await this.client.fetch(
`${RecordApi._recordApi}/${this.name}?${params}`,
);
const response = await this.client.fetch(`${this._path}?${params}`);
return (await response.json()) as ListResponse<T>;
}
@@ -208,8 +205,8 @@ export class RecordApi {
const expand = opt?.expand;
const response = await this.client.fetch(
expand
? `${RecordApi._recordApi}/${this.name}/${id}?expand=${expand.join(",")}`
: `${RecordApi._recordApi}/${this.name}/${id}`,
? `${this._path}/${id}?expand=${expand.join(",")}`
: `${this._path}/${id}`,
);
return (await response.json()) as T;
}
@@ -217,7 +214,7 @@ export class RecordApi {
public async create<T = Record<string, unknown>>(
record: T,
): Promise<string | number> {
const response = await this.client.fetch(this._createApi, {
const response = await this.client.fetch(this._path, {
method: "POST",
body: JSON.stringify(record),
});
@@ -228,7 +225,7 @@ export class RecordApi {
public async createBulk<T = Record<string, unknown>>(
records: T[],
): Promise<(string | number)[]> {
const response = await this.client.fetch(this._createApi, {
const response = await this.client.fetch(this._path, {
method: "POST",
body: JSON.stringify(records),
});
@@ -240,22 +237,20 @@ export class RecordApi {
id: string | number,
record: Partial<T>,
): Promise<void> {
await this.client.fetch(`${RecordApi._recordApi}/${this.name}/${id}`, {
await this.client.fetch(`${this._path}/${id}`, {
method: "PATCH",
body: JSON.stringify(record),
});
}
public async delete(id: string | number): Promise<void> {
await this.client.fetch(`${RecordApi._recordApi}/${this.name}/${id}`, {
await this.client.fetch(`${this._path}/${id}`, {
method: "DELETE",
});
}
public async subscribe(id: string | number): Promise<ReadableStream<Event>> {
const response = await this.client.fetch(
`${RecordApi._recordApi}/${this.name}/subscribe/${id}`,
);
const response = await this.client.fetch(`${this._path}/subscribe/${id}`);
const body = response.body;
if (!body) {
throw Error("Subscription reader is null.");
@@ -278,24 +273,10 @@ export class RecordApi {
return body.pipeThrough(transformStream);
}
public imageUri(id: string | number, colName: string): URL {
return new URL(
`/${RecordApi._recordApi}/${this.name}/${id}/file/${colName}`,
this.client.site,
);
}
public imagesUri(id: string | number, colName: string, index: number): URL {
return new URL(
`/${RecordApi._recordApi}/${this.name}/${id}/files/${colName}/${index}`,
this.client.site,
);
}
}
class ThinClient {
constructor(public readonly base: URL) {}
constructor(public readonly base: URL | undefined) {}
async fetch(
path: string,
@@ -305,7 +286,7 @@ class ThinClient {
// NOTE: We need to merge the headers in such a complicated fashion
// to avoid user-provided `init` with headers unintentionally suppressing
// the credentials.
const response = await fetch(new URL(path, this.base), {
const response = await fetch(this.base ? new URL(path, this.base) : path, {
credentials: isDev ? "include" : "same-origin",
...init,
headers: init
@@ -327,17 +308,14 @@ type ClientOptions = {
/// Client for interacting with TrailBase auth and record APIs.
export class Client {
private static readonly _authApi = "api/auth/v1";
private static readonly _authUi = "_/auth";
private readonly _client: ThinClient;
private readonly _authChange:
| undefined
| ((client: Client, user?: User) => void);
private _tokenState: TokenState;
constructor(site: URL | string, opts?: ClientOptions) {
this._client = new ThinClient(new URL(site));
constructor(baseUrl: URL | string | undefined, opts?: ClientOptions) {
this._client = new ThinClient(baseUrl ? new URL(baseUrl) : undefined);
this._authChange = opts?.onAuthChange;
// Note: this is a double assignment to _tokenState to ensure the linter
@@ -345,12 +323,12 @@ export class Client {
this._tokenState = this.setTokenState(buildTokenState(opts?.tokens), true);
}
public static init(site: URL | string, opts?: ClientOptions): Client {
public static init(site?: URL | string, opts?: ClientOptions): Client {
return new Client(site, opts);
}
public static async tryFromCookies(
site: URL | string,
site?: URL | string,
opts?: ClientOptions,
): Promise<Client> {
const client = new Client(site, opts);
@@ -368,7 +346,7 @@ export class Client {
return client;
}
public get site(): URL {
public get base(): URL | undefined {
return this._client.base;
}
@@ -387,7 +365,7 @@ export class Client {
public async avatarUrl(): Promise<string | undefined> {
const user = this.user();
if (user) {
const response = await this.fetch(`${Client._authApi}/avatar/${user.id}`);
const response = await this.fetch(`${authApiBasePath}/avatar/${user.id}`);
const json = (await response.json()) as { avatar_url: string };
return json.avatar_url;
}
@@ -395,7 +373,7 @@ export class Client {
}
public async login(email: string, password: string): Promise<void> {
const response = await this.fetch(`${Client._authApi}/login`, {
const response = await this.fetch(`${authApiBasePath}/login`, {
method: "POST",
body: JSON.stringify({
email: email,
@@ -408,25 +386,18 @@ export class Client {
);
}
public loginUri(redirect?: string): URL {
return new URL(
`/${Client._authUi}/login?${redirect ? `redirect_to=${redirect}` : ""}`,
this.site,
);
}
public async logout(): Promise<boolean> {
try {
const refresh_token = this._tokenState.state?.tokens.refresh_token;
if (refresh_token) {
await this.fetch(`${Client._authApi}/logout`, {
await this.fetch(`${authApiBasePath}/logout`, {
method: "POST",
body: JSON.stringify({
refresh_token,
} as LogoutRequest),
});
} else {
await this.fetch(`${Client._authApi}/logout`);
await this.fetch(`${authApiBasePath}/logout`);
}
} catch (err) {
console.debug(err);
@@ -435,20 +406,13 @@ export class Client {
return true;
}
public logoutUri(redirect?: string): URL {
return new URL(
`/${Client._authApi}/logout?${redirect ? `redirect_to=${redirect}` : ""}`,
this.site,
);
}
public async deleteUser(): Promise<void> {
await this.fetch(`${Client._authApi}/delete`);
await this.fetch(`${authApiBasePath}/delete`);
this.setTokenState(buildTokenState(undefined));
}
public async changeEmail(email: string): Promise<void> {
await this.fetch(`${Client._authApi}/change_email`, {
await this.fetch(`${authApiBasePath}/change_email`, {
method: "POST",
body: JSON.stringify({
new_email: email,
@@ -457,7 +421,7 @@ export class Client {
}
public async checkCookies(): Promise<Tokens | undefined> {
const response = await this.fetch(`${Client._authApi}/status`);
const response = await this.fetch(`${authApiBasePath}/status`);
const status: LoginStatusResponse = await response.json();
const authToken = status?.auth_token;
@@ -484,7 +448,7 @@ export class Client {
private async refreshTokensImpl(refreshToken: string): Promise<TokenState> {
const response = await this._client.fetch(
`${Client._authApi}/refresh`,
`${authApiBasePath}/refresh`,
this._tokenState.headers,
{
method: "POST",
@@ -526,7 +490,7 @@ export class Client {
}
/// Fetches data from TrailBase endpoints, e.g.:
/// const response = await client.fetch("api/auth/v1/status");
/// const response = await client.fetch("/api/auth/v1/status");
///
/// Unlike native fetch, will throw in case !response.ok.
public async fetch(path: string, init?: FetchOptions): Promise<Response> {
@@ -553,6 +517,26 @@ export class Client {
}
}
const recordApiBasePath = "/api/records/v1";
const authApiBasePath = "/api/auth/v1";
export function filePath(
apiName: string,
recordId: string | number,
columnName: string,
): string {
return `${recordApiBasePath}/${apiName}/${recordId}/file/${columnName}`;
}
export function filesPath(
apiName: string,
recordId: string | number,
columnName: string,
index: number,
): string {
return `${recordApiBasePath}/${apiName}/${recordId}/files/${columnName}/${index}`;
}
function _isDev(): boolean {
type ImportMeta = {
env: object | undefined;