From bb4ded3a309dabdfec0bcac893b62af684b29a1d Mon Sep 17 00:00:00 2001 From: simonredfern Date: Sun, 4 Oct 2026 11:57:39 +0200 Subject: [PATCH] get app directory so can show Manager etc. --- release_notes.md | 8 + src/lib/obp/appDirectory.test.ts | 36 +++ src/lib/obp/appDirectory.ts | 49 ++++ src/lib/obp/dynamicSummary.test.ts | 109 +++++++++ src/lib/obp/dynamicSummary.ts | 96 ++++++++ src/lib/obp/entityPages.test.ts | 20 ++ src/lib/obp/entityPages.ts | 57 +++++ src/routes/+layout.svelte | 2 +- src/routes/+page.server.ts | 12 + src/routes/+page.svelte | 71 +++++- src/routes/dynamic-entities/+page.server.ts | 75 +++--- src/routes/dynamic-entities/+page.svelte | 242 ++++++++++++++++---- src/routes/status/+page.svelte | 2 +- 13 files changed, 681 insertions(+), 98 deletions(-) create mode 100644 src/lib/obp/appDirectory.test.ts create mode 100644 src/lib/obp/appDirectory.ts create mode 100644 src/lib/obp/dynamicSummary.test.ts create mode 100644 src/lib/obp/dynamicSummary.ts create mode 100644 src/lib/obp/entityPages.test.ts create mode 100644 src/lib/obp/entityPages.ts create mode 100644 src/routes/+page.server.ts diff --git a/release_notes.md b/release_notes.md index 11d97ec..7c65414 100644 --- a/release_notes.md +++ b/release_notes.md @@ -25,6 +25,14 @@ comes from `PUBLIC_OBP_BASE_URL`; the response shape is unchanged. access. Otherwise signed-out visitors get a 403 and the registry pages show an error with no data. +4. For acceptable speed, OBP must use its SQL indexes for the joins. Set + `dynamic_entity.indexing.backend=auto` in OBP's props (the default, `inmemory`, + reads every record of every joined entity on each call), on Postgres or SQL Server, + then restart OBP. The entities' join fields must be declared indexed (run the + OGCR-DynamicEntities index/update scripts) and their indexes must reach state + `ready` in the `dynamicentityindex` table. Without this the registry with ~150 + activities takes ~15s to load. + Check it with an anonymous call; it should return `{"activities": [...], "count": N}`: ```sh diff --git a/src/lib/obp/appDirectory.test.ts b/src/lib/obp/appDirectory.test.ts new file mode 100644 index 0000000..4bf2b71 --- /dev/null +++ b/src/lib/obp/appDirectory.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import { parseAppDirectory } from './appDirectory'; + +describe('parseAppDirectory', () => { + it('maps each name to its URL', () => { + expect( + parseAppDirectory({ + app_directory: [ + { name: 'public_obp_api_manager_url', value: 'https://apimanager.example.org' }, + { name: 'public_obp_mcp_url', value: 'http://localhost:9101' } + ] + }) + ).toEqual({ + public_obp_api_manager_url: 'https://apimanager.example.org', + public_obp_mcp_url: 'http://localhost:9101' + }); + }); + + it('drops values that are not http(s) URLs, so every one can be a link', () => { + expect( + parseAppDirectory({ + app_directory: [ + { name: 'a', value: 'javascript:alert(1)' }, + { name: 'b', value: '' }, + { name: 'c', value: 42 }, + { value: 'https://no-name.example.org' } + ] + }) + ).toEqual({}); + }); + + it('is empty for a body without an app_directory list', () => { + expect(parseAppDirectory({ code: 404 })).toEqual({}); + expect(parseAppDirectory(null)).toEqual({}); + }); +}); diff --git a/src/lib/obp/appDirectory.ts b/src/lib/obp/appDirectory.ts new file mode 100644 index 0000000..8a74d06 --- /dev/null +++ b/src/lib/obp/appDirectory.ts @@ -0,0 +1,49 @@ +import { env } from '$env/dynamic/public'; + +/** + * Where the other apps of this OBP deployment live (API Manager, MCP server, Portal…), + * as OBP-API publishes them at GET /obp/v6.0.0/app-directory. Public: no access token. + * + * Asking OBP rather than configuring each URL here keeps the app pointing at the same + * deployment as the OBP it already talks to, with one source of truth for the links. + */ +export const APP_DIRECTORY_PATH = '/obp/v6.0.0/app-directory'; + +/** The app-directory names this app links to. */ +export const API_MANAGER_URL = 'public_obp_api_manager_url'; +export const MCP_URL = 'public_obp_mcp_url'; + +/** name -> URL, for the entries that hold an http(s) URL. */ +export type AppDirectory = Record; + +/** Turns the endpoint's `{ app_directory: [{ name, value }] }` into a lookup, dropping + * anything that is not an http(s) URL so it can be used as a link as-is. */ +export function parseAppDirectory(body: unknown): AppDirectory { + const entries = (body as { app_directory?: unknown })?.app_directory; + const directory: AppDirectory = {}; + if (!Array.isArray(entries)) return directory; + for (const entry of entries) { + const { name, value } = (entry ?? {}) as { name?: unknown; value?: unknown }; + if (typeof name === 'string' && typeof value === 'string' && /^https?:\/\//i.test(value)) { + directory[name] = value; + } + } + return directory; +} + +/** + * Fetch the app directory. Never throws: the links it feeds are extras, so an OBP that + * is down or too old to have the endpoint just means they are not shown. + * + * Pass the `fetch` from a SvelteKit `load` so the request participates in SSR. + */ +export async function getAppDirectory(fetchFn: typeof fetch = fetch): Promise { + const base = env.PUBLIC_OBP_BASE_URL?.replace(/\/$/, '') ?? ''; + try { + const response = await fetchFn(`${base}${APP_DIRECTORY_PATH}`); + if (!response.ok) return {}; + return parseAppDirectory(await response.json()); + } catch { + return {}; + } +} diff --git a/src/lib/obp/dynamicSummary.test.ts b/src/lib/obp/dynamicSummary.test.ts new file mode 100644 index 0000000..edc276e --- /dev/null +++ b/src/lib/obp/dynamicSummary.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest'; +import { categorize, summarizeResourceDocs } from './dynamicSummary'; + +function entityDocs(name: string, fields: string[], bank = 'ogcr') { + const base = `/obp/dynamic-entity/banks/${bank}/${name}`; + const properties = Object.fromEntries(fields.map((f) => [f, { type: 'string' }])); + const role = (verb: string) => [{ role: `Can${verb}DynamicEntityRecord_${name}` }]; + return [ + { + request_verb: 'POST', + specified_url: base, + roles: role('Create'), + typed_request_body: { type: 'object', properties } + }, + { request_verb: 'GET', specified_url: base, roles: role('Get') }, + { request_verb: 'GET', specified_url: `${base}/${name.toUpperCase()}_ID`, roles: role('Get') }, + { + request_verb: 'PUT', + specified_url: `${base}/${name.toUpperCase()}_ID`, + roles: role('Update'), + typed_request_body: { properties } + }, + { + request_verb: 'DELETE', + specified_url: `${base}/${name.toUpperCase()}_ID`, + roles: role('Delete') + } + ]; +} + +const shape = (name: string, ...fields: string[]) => ({ name, fields }); + +describe('categorize', () => { + it('puts entities with fewer than 4 columns in lookup tables', () => { + expect(categorize(shape('country', 'country_id', 'country_name'))).toBe('lookup'); + expect(categorize(shape('x', 'x_id', 'a', 'b'))).toBe('lookup'); + expect(categorize(shape('x', 'x_id', 'a', 'b', 'c'))).toBe('main'); + }); + + it('puts entities made only of *_id columns in link tables', () => { + expect(categorize(shape('parcel_activity', 'parcel_id', 'activity_id'))).toBe('link'); + expect(categorize(shape('wide_link', 'a_id', 'b_id', 'c_id', 'd_id'))).toBe('link'); + }); + + it('does not call a table with one id, or an id plus a value, a link table', () => { + expect(categorize(shape('only_id', 'only_id_id'))).toBe('lookup'); + expect( + categorize( + shape('user_operator_relationship', 'user_id', 'operator_id', 'relationship_to_operator') + ) + ).toBe('lookup'); + }); + + it('treats a *_on_chain entity as on-chain whatever its columns', () => { + expect(categorize(shape('activity_on_chain', 'activity_id', 'token_id', 'minted_at'))).toBe( + 'onChain' + ); + expect(categorize(shape('link_on_chain', 'a_id', 'b_id'))).toBe('onChain'); + }); +}); + +describe('summarizeResourceDocs', () => { + const docs = [ + ...entityDocs('country', ['country_id', 'country_name']), + ...entityDocs('parcel_activity', ['parcel_id', 'activity_id']), + ...entityDocs('activity', ['activity_id', 'name', 'summary', 'city', 'country_id']), + ...entityDocs('activity_on_chain', ['activity_id', 'token_id', 'minted_at', 'token_uri']), + { + request_verb: 'GET', + specified_url: '/obp/dynamic-endpoint/dynamic-resource-doc/registry/activities-query', + roles: [] + }, + { + request_verb: 'GET', + specified_url: '/obp/dynamic-endpoint/dynamic-resource-doc/registry/private', + roles: [{ role: 'CanReadRegistry' }] + } + ]; + const summary = summarizeResourceDocs(docs); + + it('sorts each entity into its category with its column count', () => { + expect(summary.entities).toEqual({ + lookup: [{ name: 'country', columns: 2 }], + link: [{ name: 'parcel_activity', columns: 2 }], + main: [{ name: 'activity', columns: 5 }], + onChain: [{ name: 'activity_on_chain', columns: 4 }] + }); + }); + + it('counts entity and resource doc endpoints apart', () => { + expect(summary.endpoints).toEqual({ entity: 20, resourceDoc: 2 }); + }); + + it('counts distinct roles, and none for a public endpoint', () => { + // Create, Get, Update, Delete per entity; GET-by-id shares Get. + expect(summary.roles).toEqual({ entity: 16, resourceDoc: 1 }); + }); + + it('reads system-level entity URLs, which have no bank segment', () => { + const system = summarizeResourceDocs([ + { + request_verb: 'POST', + specified_url: '/obp/dynamic-entity/parcel', + typed_request_body: { properties: { a: {}, b: {}, c: {}, d: {} } } + } + ]); + expect(system.entities.main).toEqual([{ name: 'parcel', columns: 4 }]); + }); +}); diff --git a/src/lib/obp/dynamicSummary.ts b/src/lib/obp/dynamicSummary.ts new file mode 100644 index 0000000..adbdf94 --- /dev/null +++ b/src/lib/obp/dynamicSummary.ts @@ -0,0 +1,96 @@ +/** + * A summary of one space's Dynamic Entities and Dynamic Resource Docs, counted from + * OBP's resource docs for that space (GET /obp/v6.0.0/banks/BANK_ID/resource-docs/ + * v6.0.0/obp?content=dynamic). Public: the resource docs need no access token. + * + * Every Dynamic Entity contributes a set of generated endpoints, each with its own + * Role; every Dynamic Resource Doc is one endpoint with its own (possibly no) Role. + * An entity's columns are the properties of its create (POST) request body. + */ + +/** Fewer columns than this makes an entity a lookup table (e.g. country: id + name). */ +export const LOOKUP_MAX_COLUMNS_EXCLUSIVE = 4; + +/** Mirrors of the chain kept by OGCR-chain-cache are named `_on_chain`. */ +const ON_CHAIN_SUFFIX = '_on_chain'; + +export type EntityCategory = 'lookup' | 'link' | 'main' | 'onChain'; + +export interface EntitySummary { + name: string; + columns: number; +} + +/** What categorizing an entity needs: its name and its column names. */ +export interface EntityShape { + name: string; + fields: string[]; +} + +export interface DynamicSummary { + entities: Record; + endpoints: { entity: number; resourceDoc: number }; + /** Distinct Role names. A public endpoint has none. */ + roles: { entity: number; resourceDoc: number }; +} + +interface ResourceDoc { + request_verb?: string; + specified_url?: string; + roles?: { role?: string }[] | null; + typed_request_body?: { properties?: Record } | null; +} + +/** + * On-chain by name first: a chain mirror is one whatever its width. Then a link table, + * which only joins other entities: every column is an `*_id` and there are at least two + * (e.g. parcel_activity: parcel_id, activity_id). Then lookup tables by width. + */ +export function categorize(entity: EntityShape): EntityCategory { + if (entity.name.endsWith(ON_CHAIN_SUFFIX)) return 'onChain'; + const { fields } = entity; + if (fields.length >= 2 && fields.every((field) => field.endsWith('_id'))) return 'link'; + return fields.length < LOOKUP_MAX_COLUMNS_EXCLUSIVE ? 'lookup' : 'main'; +} + +/** The entity a collection URL creates records of: `/obp/dynamic-entity[/banks/X]/`. */ +const ENTITY_COLLECTION = /\/dynamic-entity\/(?:banks\/[^/]+\/)?([^/{}]+)$/; + +export function summarizeResourceDocs(docs: ResourceDoc[]): DynamicSummary { + const entities: Record = { + lookup: [], + link: [], + main: [], + onChain: [] + }; + const endpoints = { entity: 0, resourceDoc: 0 }; + const roles = { entity: new Set(), resourceDoc: new Set() }; + + for (const doc of docs) { + const url = doc.specified_url ?? ''; + const kind = url.includes('/dynamic-entity/') + ? 'entity' + : url.includes('/dynamic-endpoint/') + ? 'resourceDoc' + : null; + if (!kind) continue; + + endpoints[kind] += 1; + for (const { role } of doc.roles ?? []) if (role) roles[kind].add(role); + + const match = + kind === 'entity' && doc.request_verb === 'POST' ? url.match(ENTITY_COLLECTION) : null; + if (match) { + const name = match[1]; + const fields = Object.keys(doc.typed_request_body?.properties ?? {}); + entities[categorize({ name, fields })].push({ name, columns: fields.length }); + } + } + + for (const list of Object.values(entities)) list.sort((a, b) => a.name.localeCompare(b.name)); + return { + entities, + endpoints, + roles: { entity: roles.entity.size, resourceDoc: roles.resourceDoc.size } + }; +} diff --git a/src/lib/obp/entityPages.test.ts b/src/lib/obp/entityPages.test.ts new file mode 100644 index 0000000..44e976e --- /dev/null +++ b/src/lib/obp/entityPages.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; +import { apiManagerEntityHref, entityPage } from './entityPages'; + +describe('apiManagerEntityHref', () => { + it("links to the entity's page in its space, by name", () => { + expect(apiManagerEntityHref('https://apimanager.example.org/', 'ogcr', 'activity')).toBe( + 'https://apimanager.example.org/dynamic-entities/system/activity?bank_id=ogcr' + ); + }); +}); + +describe('entityPage', () => { + it('names the page an entity is used on', () => { + expect(entityPage('activity_on_chain')).toEqual({ href: '/chain', label: 'Chain' }); + }); + + it('is null for an entity no page uses', () => { + expect(entityPage('audit_report')).toBeNull(); + }); +}); diff --git a/src/lib/obp/entityPages.ts b/src/lib/obp/entityPages.ts new file mode 100644 index 0000000..25934ab --- /dev/null +++ b/src/lib/obp/entityPages.ts @@ -0,0 +1,57 @@ +/** + * Where each Dynamic Entity is put to use in this app, for the Entities page to link + * to. One page per entity: the one a reader would go to see its records in context. + * Kept by hand, alongside the routes that read these entities; an entity no page reads + * yet has no entry and gets no link. + */ + +export interface EntityPage { + href: string; + label: string; +} + +const ACTIVITIES: EntityPage = { href: '/activities', label: 'Activities' }; +const OPERATORS: EntityPage = { href: '/my/operators', label: 'Operators' }; +const REGISTRY: EntityPage = { href: '/registry/activities', label: 'Registry' }; +const CHAIN: EntityPage = { href: '/chain', label: 'Chain' }; + +const PAGES: Record = { + activity: ACTIVITIES, + activity_verification: ACTIVITIES, + activity_monitoring_period_verification: ACTIVITIES, + activity_parcel_verification: ACTIVITIES, + parcel: ACTIVITIES, + parcel_owner_verification: ACTIVITIES, + parcel_monitoring_period_verification: ACTIVITIES, + + operator: OPERATORS, + user_operator_relationship: OPERATORS, + + country: REGISTRY, + certificate_of_compliance: REGISTRY, + + activity_on_chain: CHAIN, + parcel_on_chain: CHAIN, + certification_on_chain: CHAIN, + carbon_credit_batch_on_chain: CHAIN, + carbon_credit_balance_on_chain: CHAIN, + chain_sync_status: CHAIN +}; + +export function entityPage(entityName: string): EntityPage | null { + return PAGES[entityName] ?? null; +} + +/** + * The entity's page in API Manager. API Manager finds an entity of a space by its + * name as well as by its dynamic_entity_id; the name is what the public resource docs + * give us, the id needs a logged-in caller with a management Role. + */ +export function apiManagerEntityHref( + apiManagerUrl: string, + space: string, + entityName: string +): string { + const base = apiManagerUrl.replace(/\/$/, ''); + return `${base}/dynamic-entities/system/${encodeURIComponent(entityName)}?bank_id=${encodeURIComponent(space)}`; +} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 142315c..a518b18 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -25,7 +25,7 @@ chain: 'Chain', my: 'My', operators: 'Operators', - 'dynamic-entities': 'Dynamic Entities', + 'dynamic-entities': 'Entities', create: 'Create', user: 'My Account', login: 'Login', diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts new file mode 100644 index 0000000..b50abba --- /dev/null +++ b/src/routes/+page.server.ts @@ -0,0 +1,12 @@ +import type { PageServerLoad } from './$types'; +import { API_MANAGER_URL, MCP_URL, getAppDirectory } from '$lib/obp/appDirectory'; + +/** The OBP tools linked below the fold, located through OBP's app directory. A tool + * OBP has no URL for is null, and its card is left out. */ +export const load: PageServerLoad = async ({ fetch }) => { + const directory = await getAppDirectory(fetch); + return { + apiManagerUrl: directory[API_MANAGER_URL] ?? null, + mcpUrl: directory[MCP_URL] ?? null + }; +}; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 31e08cb..eecf0ef 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,6 +1,19 @@ -
+ + OGCR DCR + + + +
OGCR -

Welcome to OGCR App

+

OGCR DCR

- An example application to interact with the OGCR entities + Explore features and architecture of the OGCR DCR

@@ -67,4 +111,21 @@ {/each}
-
+ + +{#if tools.length > 0} +
+
+

OBP tools

+ +
+
+{/if} diff --git a/src/routes/dynamic-entities/+page.server.ts b/src/routes/dynamic-entities/+page.server.ts index 9429748..c28f910 100644 --- a/src/routes/dynamic-entities/+page.server.ts +++ b/src/routes/dynamic-entities/+page.server.ts @@ -1,51 +1,38 @@ import type { PageServerLoad } from './$types'; -import { obp_requests } from '$lib/obp/requests'; +import { env } from '$env/dynamic/public'; +import { ENTITY_ROLE_BANK_ID } from '$lib/constants/entities'; +import { summarizeResourceDocs, type DynamicSummary } from '$lib/obp/dynamicSummary'; +import { API_MANAGER_URL, getAppDirectory } from '$lib/obp/appDirectory'; -export const load: PageServerLoad = async ({ locals }) => { - const session = locals.session; - const accessToken = session.data.oauth?.access_token; - - if (!accessToken) { - return { - isAuthenticated: false, - dynamicEntities: null, - error: null - }; - } +/** + * Public, like the resource docs it reads. The bank-level resource docs list this + * deployment's space only (SYS for system-level entities), so other spaces on the same + * OBP are not counted. + */ +export const load: PageServerLoad = async ({ fetch }) => { + const base = env.PUBLIC_OBP_BASE_URL?.replace(/\/$/, '') ?? ''; + const path = `/obp/v6.0.0/banks/${encodeURIComponent(ENTITY_ROLE_BANK_ID)}/resource-docs/v6.0.0/obp?content=dynamic`; + let summary: DynamicSummary | null = null; + let error: string | null = null; + // API Manager is where each entity's definition can be looked at in detail. + const directory = getAppDirectory(fetch); try { - // Fetch dynamic endpoint resource docs to discover all dynamic entities - const response = await obp_requests.get( - '/obp/v6.0.0/resource-docs/v6.0.0/obp?content=dynamic', - accessToken - ); - - // Extract unique entity names from the tags - const entityNames = new Set(); - for (const doc of response.resource_docs || []) { - for (const tag of doc.tags || []) { - if (tag.startsWith('_') && tag !== 'Dynamic-Entity' && tag !== 'Dynamic') { - // Tags like "_Ogcr5_project" - strip leading underscore and lowercase - entityNames.add(tag.slice(1).toLowerCase()); - } - } + const response = await fetch(`${base}${path}`); + if (response.ok) { + const body = (await response.json()) as { resource_docs?: [] }; + summary = summarizeResourceDocs(body.resource_docs ?? []); + } else { + error = `OBP returned HTTP ${response.status} for ${path}`; } - - const dynamicEntities = [...entityNames].sort().map((name) => ({ - entity_name: name - })); - - return { - isAuthenticated: true, - dynamicEntities, - error: null - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - return { - isAuthenticated: true, - dynamicEntities: null, - error: errorMessage - }; + } catch (e) { + error = e instanceof Error ? e.message : 'Could not reach OBP'; } + + return { + space: ENTITY_ROLE_BANK_ID, + summary, + error, + apiManagerUrl: (await directory)[API_MANAGER_URL] ?? null + }; }; diff --git a/src/routes/dynamic-entities/+page.svelte b/src/routes/dynamic-entities/+page.svelte index 2302e7b..76f606b 100644 --- a/src/routes/dynamic-entities/+page.svelte +++ b/src/routes/dynamic-entities/+page.svelte @@ -1,56 +1,204 @@ -
-
-
- -

Dynamic Entities

-
- - {#if data.isAuthenticated} -
- - - Refresh - + + Entities — OGCR DCR + + +
+

Entities

+

+ The OBP Dynamic Entities and Dynamic Resource Docs of space {data.space}, counted + from OBP's resource docs. +

+ + {#if data.error} + + {:else if summary} +
+

Counts

+
+ {#each categories as category (category.key)} + + {#snippet icon()} + + {/snippet} + + {/each} + + {#snippet icon()} + + {/snippet} + + + {#snippet icon()} + + {/snippet} +
- {/if} -
- - {#if !data.isAuthenticated} -
- -

Authentication Required

-

- Please log in to view and manage dynamic entities. -

- Login -
- {:else if data.error} -
-

Error Loading Entities

-

{data.error}

-
- {:else if data.dynamicEntities && data.dynamicEntities.length === 0} -
- -

No Dynamic Entities Found

-

- There are no dynamic entities configured in the system yet. -

-
- {:else if data.dynamicEntities} -
- {#each data.dynamicEntities as entity} -
-

{entity.entity_name}

-
- {/each} -
+ + +
+

Entities by category

+ +
+ {#each categories as category (category.key)} + {@const entities = summary.entities[category.key]} +
+

{category.title} ({entities.length})

+ {#if entities.length === 0} +

None

+ {:else} +
    + {#each entities as entity (entity.name)} + {@const page = entityPage(entity.name)} +
  • +
    + {entity.name} + {#if data.apiManagerUrl || page} + + {#if data.apiManagerUrl} + + Details in API Manager + + {/if} + {#if page} + Used in {page.label} + {/if} + + {/if} +
    + + {entity.columns} + {entity.columns === 1 ? 'column' : 'columns'} + +
  • + {/each} +
+ {/if} +
+ {/each} +
+
{/if}
+ + diff --git a/src/routes/status/+page.svelte b/src/routes/status/+page.svelte index cc74786..f04bd13 100644 --- a/src/routes/status/+page.svelte +++ b/src/routes/status/+page.svelte @@ -5,4 +5,4 @@ let { data }: { data: PageData } = $props(); - +