Extend TypeScript client and add Leaflet asset demo.
CI / test (pull_request) Successful in 3s

This adds typed asset APIs to the geo client, covers the 3D/image upload-share flow in integration tests, and introduces a simple Leaflet web demo that places objects on map features and manages sharing visibility via backend links.

Made-with: Cursor
This commit is contained in:
2026-03-02 21:21:52 +00:00
parent f6f46f6db1
commit 1292f204a4
14 changed files with 1009 additions and 47 deletions
+159 -3
View File
@@ -27,6 +27,22 @@ async function createMockServer(): Promise<{ url: string; server: ReturnType<typ
const sessions = new Map<string, string>();
let collectionId = 0;
let featureId = 0;
let assetId = 0;
const collectionsByUser = new Map<string, Array<{ id: string; name: string }>>();
const featuresByCollection = new Map<string, Array<{ id: string; geometry: unknown; properties: Record<string, unknown> }>>();
const assetsByOwner = new Map<string, Array<{
id: string;
ownerKey: string;
checksum: string;
ext: string;
kind: "image" | "3d";
mimeType?: string;
sizeBytes: number;
objectKey: string;
isPublic: boolean;
createdAt: string;
updatedAt: string;
}>>();
const server = Bun.serve({
port: 0,
@@ -98,35 +114,138 @@ async function createMockServer(): Promise<{ url: string; server: ReturnType<typ
if (!user && path.startsWith("/v1/collections") && method !== "GET") {
return Response.json({ error: "unauthorized" }, { status: 401 });
}
if (!user && path.startsWith("/v1/assets")) {
return Response.json({ error: "unauthorized" }, { status: 401 });
}
// POST /v1/collections
if (method === "POST" && path === "/v1/collections") {
const body = (await req.json()) as { name?: string };
collectionId++;
const c = { id: `col-${collectionId}`, name: body?.name ?? "Unnamed" };
const list = collectionsByUser.get(user!) ?? [];
list.push(c);
collectionsByUser.set(user!, list);
return Response.json(c, { status: 201 });
}
// GET /v1/collections
if (method === "GET" && path === "/v1/collections") {
return Response.json({ collections: [] });
return Response.json({ collections: collectionsByUser.get(user ?? "") ?? [] });
}
// POST /v1/collections/:id/features
if (method === "POST" && path.match(/^\/v1\/collections\/[^/]+\/features$/)) {
const colId = path.split("/")[3]!;
const owned = (collectionsByUser.get(user!) ?? []).some((c) => c.id === colId);
if (!owned) {
return Response.json({ error: "forbidden" }, { status: 403 });
}
const body = (await req.json()) as { geometry?: unknown; properties?: unknown };
featureId++;
const f = {
id: `feat-${featureId}`,
geometry: body?.geometry ?? { type: "Point", coordinates: [0, 0] },
properties: body?.properties ?? {},
properties: (body?.properties as Record<string, unknown>) ?? {},
};
const list = featuresByCollection.get(colId) ?? [];
list.push(f);
featuresByCollection.set(colId, list);
return Response.json(f, { status: 201 });
}
// GET /v1/collections/:id/features
if (method === "GET" && path.match(/^\/v1\/collections\/[^/]+\/features$/)) {
return Response.json({ features: [] });
const colId = path.split("/")[3]!;
const features = featuresByCollection.get(colId) ?? [];
const ownerAssets = assetsByOwner.get(user ?? "") ?? [];
const withAssets = features.map((f) => {
const assets = ownerAssets
.filter((a) => (f.properties.assets as Array<{ id: string }> | undefined)?.some((x) => x.id === a.id))
.map((a) => ({
id: a.id,
kind: a.kind,
checksum: a.checksum,
ext: a.ext,
isPublic: a.isPublic,
link: `/v1/assets/${a.id}/download`,
}));
return { ...f, properties: { ...f.properties, assets } };
});
return Response.json({ features: withAssets });
}
// POST /v1/assets
if (method === "POST" && path === "/v1/assets") {
const body = (await req.json()) as {
featureId?: string;
checksum?: string;
ext?: string;
kind?: "image" | "3d";
mimeType?: string;
sizeBytes?: number;
name?: string;
description?: string;
isPublic?: boolean;
};
if (!body.featureId || !body.checksum || !body.ext || !body.kind) {
return Response.json({ error: "missing fields" }, { status: 400 });
}
const ownerAssets = assetsByOwner.get(user!) ?? [];
const existing = ownerAssets.find(
(a) => a.checksum === body.checksum.toLowerCase() && a.ext === body.ext.toLowerCase()
);
const now = new Date().toISOString();
const asset =
existing ??
{
id: `asset-${++assetId}`,
ownerKey: user!,
checksum: body.checksum.toLowerCase(),
ext: body.ext.toLowerCase(),
kind: body.kind,
mimeType: body.mimeType,
sizeBytes: body.sizeBytes ?? 0,
objectKey: `${user!}/${body.checksum.toLowerCase()}.${body.ext.toLowerCase()}`,
isPublic: body.isPublic ?? true,
createdAt: now,
updatedAt: now,
};
if (!existing) {
ownerAssets.push(asset);
assetsByOwner.set(user!, ownerAssets);
}
for (const featureList of featuresByCollection.values()) {
for (const f of featureList) {
if (f.id === body.featureId) {
const oldAssets = Array.isArray(f.properties.assets) ? (f.properties.assets as Array<{ id: string }>) : [];
if (!oldAssets.some((x) => x.id === asset.id)) {
f.properties.assets = [...oldAssets, { id: asset.id, name: body.name, description: body.description }];
}
}
}
}
return Response.json({ asset, link: `/v1/assets/${asset.id}/download` }, { status: existing ? 200 : 201 });
}
// POST /v1/assets/:id/signed-upload
if (method === "POST" && path.match(/^\/v1\/assets\/[^/]+\/signed-upload$/)) {
const id = path.split("/")[3]!;
return Response.json({ url: `http://upload.local/${id}`, method: "PUT" });
}
// PATCH /v1/assets/:id
if (method === "PATCH" && path.match(/^\/v1\/assets\/[^/]+$/)) {
const id = path.split("/")[3]!;
const body = (await req.json()) as { isPublic?: boolean };
const ownerAssets = assetsByOwner.get(user!) ?? [];
const asset = ownerAssets.find((a) => a.id === id);
if (!asset) {
return Response.json({ error: "not found" }, { status: 404 });
}
asset.isPublic = Boolean(body.isPublic);
asset.updatedAt = new Date().toISOString();
return Response.json({ asset, link: `/v1/assets/${asset.id}/download` });
}
return new Response("Not Found", { status: 404 });
@@ -180,4 +299,41 @@ describe("GeoApiClient integration (docs flow)", () => {
const { features } = await client.listFeatures(created.id);
expect(Array.isArray(features)).toBe(true);
});
test("asset flow: create/link -> signed upload -> toggle visibility", async () => {
const keys = await client.ensureKeysInStorage();
await client.registerBySigningServiceKey(keys.publicKey, keys.privateKey);
await client.loginWithSignature(keys.publicKey, keys.privateKey);
const created = await client.createCollection("3D");
const feature = await client.createPointFeature(created.id, -16.6291, 28.4636, { name: "Palm Tree Point" });
const createdAsset = await client.createOrLinkAsset({
featureId: feature.id,
checksum: "ABCDEF0123",
ext: "glb",
kind: "3d",
mimeType: "model/gltf-binary",
sizeBytes: 1024,
name: "Palm Tree",
description: "Low poly",
isPublic: true,
});
expect(createdAsset.asset.id).toBeDefined();
expect(createdAsset.link).toBe(`/v1/assets/${createdAsset.asset.id}/download`);
expect(client.resolveRelativeLink(createdAsset.link)).toContain(`/v1/assets/${createdAsset.asset.id}/download`);
const upload = await client.getAssetSignedUploadUrl(createdAsset.asset.id, "model/gltf-binary");
expect(upload.method).toBe("PUT");
expect(upload.url).toContain(createdAsset.asset.id);
const toggled = await client.setAssetVisibility(createdAsset.asset.id, false);
expect(toggled.asset.isPublic).toBe(false);
const listed = await client.listFeatures(created.id);
const first = listed.features[0];
const assets = first.properties?.assets ?? [];
expect(assets.length).toBeGreaterThan(0);
expect(assets[0]?.id).toBe(createdAsset.asset.id);
});
});