Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions release_notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 36 additions & 0 deletions src/lib/obp/appDirectory.test.ts
Original file line number Diff line number Diff line change
@@ -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({});
});
});
49 changes: 49 additions & 0 deletions src/lib/obp/appDirectory.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;

/** 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<AppDirectory> {
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 {};
}
}
109 changes: 109 additions & 0 deletions src/lib/obp/dynamicSummary.test.ts
Original file line number Diff line number Diff line change
@@ -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 }]);
});
});
96 changes: 96 additions & 0 deletions src/lib/obp/dynamicSummary.ts
Original file line number Diff line number Diff line change
@@ -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 `<thing>_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<EntityCategory, EntitySummary[]>;
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<string, unknown> } | 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]/<name>`. */
const ENTITY_COLLECTION = /\/dynamic-entity\/(?:banks\/[^/]+\/)?([^/{}]+)$/;

export function summarizeResourceDocs(docs: ResourceDoc[]): DynamicSummary {
const entities: Record<EntityCategory, EntitySummary[]> = {
lookup: [],
link: [],
main: [],
onChain: []
};
const endpoints = { entity: 0, resourceDoc: 0 };
const roles = { entity: new Set<string>(), resourceDoc: new Set<string>() };

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 }
};
}
20 changes: 20 additions & 0 deletions src/lib/obp/entityPages.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
57 changes: 57 additions & 0 deletions src/lib/obp/entityPages.ts
Original file line number Diff line number Diff line change
@@ -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<string, EntityPage> = {
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)}`;
}
2 changes: 1 addition & 1 deletion src/routes/+layout.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
chain: 'Chain',
my: 'My',
operators: 'Operators',
'dynamic-entities': 'Dynamic Entities',
'dynamic-entities': 'Entities',
create: 'Create',
user: 'My Account',
login: 'Login',
Expand Down
Loading
Loading