From d5c1d201d8f1328b72917a5aac68cd5ff91b066a Mon Sep 17 00:00:00 2001 From: Maximiliano Osorio Date: Sun, 10 May 2026 13:52:57 -0400 Subject: [PATCH 1/9] feat(model-catalog-api): add mode param to getFieldSelection Adds FIELD_SELECTIONS_BY_ID map (empty) and extends getFieldSelection with a 'list' | 'byId' mode. Default 'list' preserves all existing call sites; 'byId' returns the deep variant when present, else falls back to FIELD_SELECTIONS. --- src/hasura/__tests__/field-maps.test.ts | 38 +++++++++++++++++++++++++ src/hasura/field-maps.ts | 36 +++++++++++++++++++++-- 2 files changed, 72 insertions(+), 2 deletions(-) create mode 100644 src/hasura/__tests__/field-maps.test.ts diff --git a/src/hasura/__tests__/field-maps.test.ts b/src/hasura/__tests__/field-maps.test.ts new file mode 100644 index 0000000..0bc1fd2 --- /dev/null +++ b/src/hasura/__tests__/field-maps.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from 'vitest'; +import { getFieldSelection, FIELD_SELECTIONS_BY_ID } from '../field-maps.js'; + +describe('getFieldSelection — mode parameter', () => { + it("default mode (no second arg) returns the shallow selection", () => { + const sel = getFieldSelection('modelcatalog_configuration'); + expect(sel).toContain('inputs'); + // Shallow: no presentations under inputs.input + expect(sel).not.toMatch(/inputs\s*{[^}]*input\s*{[^}]*presentations/s); + }); + + it("mode='list' returns the shallow selection", () => { + const sel = getFieldSelection('modelcatalog_configuration', 'list'); + expect(sel).not.toMatch(/inputs\s*{[^}]*input\s*{[^}]*presentations/s); + }); + + it("mode='byId' for modelcatalog_configuration returns the deep selection", () => { + const sel = getFieldSelection('modelcatalog_configuration', 'byId'); + expect(sel).toMatch(/inputs\s*{[^}]*input\s*{[^}]*presentations/s); + expect(sel).toMatch(/outputs\s*{[^}]*output\s*{[^}]*presentations/s); + expect(sel).toContain('has_short_name'); + expect(sel).toContain('has_long_name'); + }); + + it("mode='byId' falls back to shallow for tables without a deep entry", () => { + // dataset_specification has no FIELD_SELECTIONS_BY_ID entry yet + expect(FIELD_SELECTIONS_BY_ID['modelcatalog_dataset_specification']).toBeUndefined(); + const sel = getFieldSelection('modelcatalog_dataset_specification', 'byId'); + // Should equal the shallow map's entry (already deep at this layer) + expect(sel).toContain('presentations'); + expect(sel).toContain('standard_variable'); + }); + + it("unknown table returns 'id label' fallback for both modes", () => { + expect(getFieldSelection('totally_made_up_table')).toBe('id label'); + expect(getFieldSelection('totally_made_up_table', 'byId')).toBe('id label'); + }); +}); diff --git a/src/hasura/field-maps.ts b/src/hasura/field-maps.ts index 6156a1e..3c2c04e 100644 --- a/src/hasura/field-maps.ts +++ b/src/hasura/field-maps.ts @@ -548,10 +548,42 @@ label `.trim(), }; +/** + * Per-table deep field selections used ONLY by the by-id read path. + * Mirrors the structure of FIELD_SELECTIONS but adds an extra hop into + * junction tables for resources that need a single-round-trip nested read. + * + * Lookup falls back to FIELD_SELECTIONS when no entry is present, so adding + * a deep variant for a new table is purely additive — existing list/byId + * behavior for every other table is preserved. + * + * Depth note: response.ts:71 caps recursion at depth<2. Anything at depth 2 + * (e.g. VariablePresentation hoisted from inputs.input.presentations) gets + * its relationships stripped — only scalars survive to the wire. Selecting + * `standard_variable` / `unit` here would be wasted work; bump + * response.ts depth budget if that ever changes. + */ +export const FIELD_SELECTIONS_BY_ID: Record = { + // Populated in Task 2. +}; + /** * Return the GraphQL field selection string for a given Hasura table name. - * Falls back to `id label` if no specific selection is defined. + * + * @param tableName Hasura table name (e.g. `modelcatalog_configuration`). + * @param mode `'list'` (default) returns the shallow selection used by list + * routes. `'byId'` prefers FIELD_SELECTIONS_BY_ID when an entry exists, + * else falls back to the shallow map. + * + * Falls back to `id label` if neither map has the table. */ -export function getFieldSelection(tableName: string): string { +export function getFieldSelection( + tableName: string, + mode: 'list' | 'byId' = 'list', +): string { + if (mode === 'byId') { + const deep = FIELD_SELECTIONS_BY_ID[tableName]; + if (deep) return deep; + } return FIELD_SELECTIONS[tableName] ?? 'id label'; } From 73eb50a23a9df6c8699374ea331a846978bb75f4 Mon Sep 17 00:00:00 2001 From: Maximiliano Osorio Date: Sun, 10 May 2026 13:57:04 -0400 Subject: [PATCH 2/9] test(field-maps): skip deep-selection assertion until Task 2 populates the map --- src/hasura/__tests__/field-maps.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/hasura/__tests__/field-maps.test.ts b/src/hasura/__tests__/field-maps.test.ts index 0bc1fd2..5ebf029 100644 --- a/src/hasura/__tests__/field-maps.test.ts +++ b/src/hasura/__tests__/field-maps.test.ts @@ -14,7 +14,8 @@ describe('getFieldSelection — mode parameter', () => { expect(sel).not.toMatch(/inputs\s*{[^}]*input\s*{[^}]*presentations/s); }); - it("mode='byId' for modelcatalog_configuration returns the deep selection", () => { + // Un-skipped in Task 2 once FIELD_SELECTIONS_BY_ID.modelcatalog_configuration is populated. + it.skip("mode='byId' for modelcatalog_configuration returns the deep selection", () => { const sel = getFieldSelection('modelcatalog_configuration', 'byId'); expect(sel).toMatch(/inputs\s*{[^}]*input\s*{[^}]*presentations/s); expect(sel).toMatch(/outputs\s*{[^}]*output\s*{[^}]*presentations/s); From ccb8505e38df80b96c98ce395229526e937b5e30 Mon Sep 17 00:00:00 2001 From: Maximiliano Osorio Date: Sun, 10 May 2026 14:00:52 -0400 Subject: [PATCH 3/9] feat(model-catalog-api): deep field selection for modelconfigurations by-id Adds FIELD_SELECTIONS_BY_ID.modelcatalog_configuration with presentations nested under inputs.input and outputs.output. Mirror anchor comment added to FIELD_SELECTIONS.modelcatalog_dataset_specification to flag the duplicated VP selection. List path unchanged (default mode='list'). --- src/hasura/__tests__/field-maps.test.ts | 3 +- src/hasura/field-maps.ts | 150 +++++++++++++++++++++++- 2 files changed, 150 insertions(+), 3 deletions(-) diff --git a/src/hasura/__tests__/field-maps.test.ts b/src/hasura/__tests__/field-maps.test.ts index 5ebf029..0bc1fd2 100644 --- a/src/hasura/__tests__/field-maps.test.ts +++ b/src/hasura/__tests__/field-maps.test.ts @@ -14,8 +14,7 @@ describe('getFieldSelection — mode parameter', () => { expect(sel).not.toMatch(/inputs\s*{[^}]*input\s*{[^}]*presentations/s); }); - // Un-skipped in Task 2 once FIELD_SELECTIONS_BY_ID.modelcatalog_configuration is populated. - it.skip("mode='byId' for modelcatalog_configuration returns the deep selection", () => { + it("mode='byId' for modelcatalog_configuration returns the deep selection", () => { const sel = getFieldSelection('modelcatalog_configuration', 'byId'); expect(sel).toMatch(/inputs\s*{[^}]*input\s*{[^}]*presentations/s); expect(sel).toMatch(/outputs\s*{[^}]*output\s*{[^}]*presentations/s); diff --git a/src/hasura/field-maps.ts b/src/hasura/field-maps.ts index 3c2c04e..ece8d9e 100644 --- a/src/hasura/field-maps.ts +++ b/src/hasura/field-maps.ts @@ -302,6 +302,11 @@ categories { // Columns: id, label, description, has_format, has_dimensionality, position // Array relationships (junction): // presentations -> modelcatalog_dataset_specification_presentation + // + // NOTE: FIELD_SELECTIONS_BY_ID.modelcatalog_configuration mirrors the + // `presentations { presentation { ... } }` block under inputs.input / + // outputs.output. Keep these in sync; drift causes different VP shapes + // depending on whether the client GETs a config or a dataset spec by id. // ========================================================================= modelcatalog_dataset_specification: ` id @@ -564,7 +569,150 @@ label * response.ts depth budget if that ever changes. */ export const FIELD_SELECTIONS_BY_ID: Record = { - // Populated in Task 2. + // ========================================================================= + // modelcatalog_configuration — deep variant for GET /modelconfigurations/{id} + // Mirrors FIELD_SELECTIONS.modelcatalog_configuration but appends + // `presentations { presentation { ... } }` inside both inputs.input{} and + // outputs.output{}. VP at depth 2 — scalars only survive response.ts + // depth<2 guard. Keep the presentations selection in sync with + // FIELD_SELECTIONS.modelcatalog_dataset_specification. + // ========================================================================= + modelcatalog_configuration: ` +id +software_version_id +model_configuration_id +label +description +keywords +usage_notes +has_component_location +has_implementation_script_location +has_software_image +has_model_result_table +has_region +author_id +calibration_interval +calibration_method +parameter_assignment_method +valid_until +software_version { + id + label +} +author { + id + label +} +parent_configuration { + id + label +} +child_configurations { + id + label + description +} +inputs { + is_optional + input { + id + label + description + has_format + has_dimensionality + position + presentations { + presentation { + id + label + description + has_long_name + has_short_name + } + } + } +} +outputs { + output { + id + label + description + has_format + has_dimensionality + position + presentations { + presentation { + id + label + description + has_long_name + has_short_name + } + } + } +} +parameters { + parameter { + id + label + description + has_data_type + has_default_value + has_minimum_accepted_value + has_maximum_accepted_value + has_fixed_value + has_accepted_values + position + parameter_type + } +} +causal_diagrams { + causal_diagram { + id + label + } +} +time_intervals { + time_interval { + id + label + description + interval_value + interval_unit + } +} +regions { + region { + id + label + description + } +} +authors { + person { + id + label + } +} +calibrated_variables { + variable { + id + label + } +} +calibration_targets { + variable { + id + label + } +} +categories { + category { + id + label + } +} +`.trim(), }; /** From 2f704495263ddbb68197d92db70f4af29e58914b Mon Sep 17 00:00:00 2001 From: Maximiliano Osorio Date: Sun, 10 May 2026 14:02:03 -0400 Subject: [PATCH 4/9] feat(model-catalog-api): wire getById to deep field selection service.ts:183 now requests mode='byId' from getFieldSelection. List path (line 123) keeps default mode='list'. End-to-end coverage in read-shape-deep-e2e.test.ts. --- src/service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/service.ts b/src/service.ts index 2eca21f..35b94f4 100644 --- a/src/service.ts +++ b/src/service.ts @@ -180,7 +180,7 @@ class CatalogServiceImpl { return } const fullId = id - const fields = getFieldSelection(resourceConfig.hasuraTable!) + const fields = getFieldSelection(resourceConfig.hasuraTable!, 'byId') const tableSuffix = resourceConfig.hasuraTable.replace('modelcatalog_', '') const queryStr = ` From 95ee885bfd1067eee738caa49307092f28dce93e Mon Sep 17 00:00:00 2001 From: Maximiliano Osorio Date: Sun, 10 May 2026 14:05:27 -0400 Subject: [PATCH 5/9] =?UTF-8?q?test(model-catalog-api):=20e2e=20read-shape?= =?UTF-8?q?=20=E2=80=94=20deep=20Config=20tree?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /v2.0.0/modelconfigurations/{id} returns the full Config → DataSetSpec → VariablePresentation tree in one round trip. Asserts VP id/label/hasShortName surface; standardVariable/unit absent (depth-2 cap). --- src/__tests__/e2e/read-shape-deep-e2e.test.ts | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 src/__tests__/e2e/read-shape-deep-e2e.test.ts diff --git a/src/__tests__/e2e/read-shape-deep-e2e.test.ts b/src/__tests__/e2e/read-shape-deep-e2e.test.ts new file mode 100644 index 0000000..b59ed32 --- /dev/null +++ b/src/__tests__/e2e/read-shape-deep-e2e.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import type { FastifyInstance } from 'fastify'; +import { assertHasuraReachable, buildE2EApp } from './setup.js'; +import { cleanup, inject, trackId, uniqueId } from './helpers.js'; + +let app: FastifyInstance; + +beforeAll(async () => { + await assertHasuraReachable(); + app = await buildE2EApp(); +}); + +afterAll(async () => { + if (app) { + await cleanup(app); + await app.close(); + } +}); + +describe('read-shape-deep e2e — GET /modelconfigurations/{id}', () => { + it('deep read returns Config → DSS → VP tree in one round trip', async () => { + const softwareId = uniqueId('software'); + const versionId = uniqueId('softwareversion'); + const configId = uniqueId('modelconfiguration'); + const inputAId = uniqueId('datasetspecification'); + const inputBId = uniqueId('datasetspecification'); + const outputAId = uniqueId('datasetspecification'); + const outputBId = uniqueId('datasetspecification'); + const outputCId = uniqueId('datasetspecification'); + const vpInAId = uniqueId('variablepresentation'); + const vpInBId = uniqueId('variablepresentation'); + const vpOutAId = uniqueId('variablepresentation'); + const vpOutBId = uniqueId('variablepresentation'); + const vpOutCId = uniqueId('variablepresentation'); + + const post = await inject(app, 'POST', '/v2.0.0/softwares', { + id: softwareId, + type: ['Software'], + label: ['sw-deepread'], + hasVersion: [ + { + id: versionId, + type: ['SoftwareVersion'], + label: ['v-deepread'], + hasConfiguration: [ + { + id: configId, + type: ['ModelConfiguration'], + label: ['cfg-deepread'], + hasInput: [ + { id: inputAId, type: ['DataSetSpecification'], label: ['input-A'], + hasPresentation: [{ id: vpInAId, type: ['VariablePresentation'], label: ['vp-input-A'], hasLongName: ['Input A long'], hasShortName: ['input-a'] }] }, + { id: inputBId, type: ['DataSetSpecification'], label: ['input-B'], + hasPresentation: [{ id: vpInBId, type: ['VariablePresentation'], label: ['vp-input-B'], hasLongName: ['Input B long'], hasShortName: ['input-b'] }] }, + ], + hasOutput: [ + { id: outputAId, type: ['DataSetSpecification'], label: ['output-A'], + hasPresentation: [{ id: vpOutAId, type: ['VariablePresentation'], label: ['vp-output-A'], hasLongName: ['Output A long'], hasShortName: ['output-a'] }] }, + { id: outputBId, type: ['DataSetSpecification'], label: ['output-B'], + hasPresentation: [{ id: vpOutBId, type: ['VariablePresentation'], label: ['vp-output-B'], hasLongName: ['Output B long'], hasShortName: ['output-b'] }] }, + { id: outputCId, type: ['DataSetSpecification'], label: ['output-C'], + hasPresentation: [{ id: vpOutCId, type: ['VariablePresentation'], label: ['vp-output-C'], hasLongName: ['Output C long'], hasShortName: ['output-c'] }] }, + ], + }, + ], + }, + ], + }); + expect(post.statusCode).toBeGreaterThanOrEqual(200); + expect(post.statusCode).toBeLessThan(300); + + trackId('variablepresentations', vpInAId); + trackId('variablepresentations', vpInBId); + trackId('variablepresentations', vpOutAId); + trackId('variablepresentations', vpOutBId); + trackId('variablepresentations', vpOutCId); + trackId('datasetspecifications', inputAId); + trackId('datasetspecifications', inputBId); + trackId('datasetspecifications', outputAId); + trackId('datasetspecifications', outputBId); + trackId('datasetspecifications', outputCId); + trackId('modelconfigurations', configId); + trackId('softwareversions', versionId); + trackId('softwares', softwareId); + + const get = await inject(app, 'GET', `/v2.0.0/modelconfigurations/${encodeURIComponent(configId)}`); + expect(get.statusCode).toBe(200); + + type VP = { id: string; label?: string[]; hasShortName?: string[]; hasLongName?: string[]; standardVariable?: unknown; unit?: unknown }; + type DSS = { id: string; label?: string[]; hasPresentation?: VP[] }; + type Cfg = { id: string; hasInput?: DSS[]; hasOutput?: DSS[] }; + const cfg = (Array.isArray(get.body) ? get.body[0] : get.body) as Cfg; + + expect(cfg.id).toBe(configId); + expect(cfg.hasInput?.length).toBe(2); + expect(cfg.hasOutput?.length).toBe(3); + + for (const dss of [...(cfg.hasInput ?? []), ...(cfg.hasOutput ?? [])]) { + expect(dss.hasPresentation).toBeDefined(); + expect(dss.hasPresentation!.length).toBeGreaterThan(0); + const vp = dss.hasPresentation![0]; + expect(typeof vp.id).toBe('string'); + expect(vp.label).toBeDefined(); + expect(vp.hasShortName).toBeDefined(); + } + + const firstVp = cfg.hasOutput![0].hasPresentation![0]; + expect(firstVp.standardVariable).toBeUndefined(); + expect(firstVp.unit).toBeUndefined(); + + const inVpIds = new Set(cfg.hasInput!.flatMap((d) => d.hasPresentation!.map((v) => v.id))); + const outVpIds = new Set(cfg.hasOutput!.flatMap((d) => d.hasPresentation!.map((v) => v.id))); + expect(inVpIds).toEqual(new Set([vpInAId, vpInBId])); + expect(outVpIds).toEqual(new Set([vpOutAId, vpOutBId, vpOutCId])); + }); +}); From b23c9f5b22681cfa27c3af545e83b4e203c9ed64 Mon Sep 17 00:00:00 2001 From: Maximiliano Osorio Date: Sun, 10 May 2026 14:06:04 -0400 Subject: [PATCH 6/9] test(model-catalog-api): e2e isOptional hoist preserved with deep read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bug-082 class regression check — adding nested hasPresentation under inputs.input does not regress the is_optional → isOptional scalar hoist in response.ts:104-122. --- src/__tests__/e2e/read-shape-deep-e2e.test.ts | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/src/__tests__/e2e/read-shape-deep-e2e.test.ts b/src/__tests__/e2e/read-shape-deep-e2e.test.ts index b59ed32..c4d81a2 100644 --- a/src/__tests__/e2e/read-shape-deep-e2e.test.ts +++ b/src/__tests__/e2e/read-shape-deep-e2e.test.ts @@ -113,4 +113,44 @@ describe('read-shape-deep e2e — GET /modelconfigurations/{id}', () => { expect(inVpIds).toEqual(new Set([vpInAId, vpInBId])); expect(outVpIds).toEqual(new Set([vpOutAId, vpOutBId, vpOutCId])); }); + + it('preserves isOptional junction-column hoist alongside hasPresentation', async () => { + const softwareId = uniqueId('software'); + const versionId = uniqueId('softwareversion'); + const configId = uniqueId('modelconfiguration'); + const inputId = uniqueId('datasetspecification'); + const vpId = uniqueId('variablepresentation'); + + const post = await inject(app, 'POST', '/v2.0.0/softwares', { + id: softwareId, + type: ['Software'], + label: ['sw-isopt'], + hasVersion: [{ id: versionId, type: ['SoftwareVersion'], label: ['v-isopt'], + hasConfiguration: [{ id: configId, type: ['ModelConfiguration'], label: ['cfg-isopt'], + hasInput: [{ id: inputId, type: ['DataSetSpecification'], label: ['optional-input'], isOptional: true, + hasPresentation: [{ id: vpId, type: ['VariablePresentation'], label: ['vp-isopt'], hasShortName: ['opt-vp'] }] }] }] }], + }); + expect(post.statusCode).toBeGreaterThanOrEqual(200); + expect(post.statusCode).toBeLessThan(300); + + trackId('variablepresentations', vpId); + trackId('datasetspecifications', inputId); + trackId('modelconfigurations', configId); + trackId('softwareversions', versionId); + trackId('softwares', softwareId); + + const get = await inject(app, 'GET', `/v2.0.0/modelconfigurations/${encodeURIComponent(configId)}`); + expect(get.statusCode).toBe(200); + + type VP = { id: string }; + type DSS = { id: string; isOptional?: boolean; hasPresentation?: VP[] }; + type Cfg = { id: string; hasInput?: DSS[] }; + const cfg = (Array.isArray(get.body) ? get.body[0] : get.body) as Cfg; + + const target = cfg.hasInput?.find((d) => d.id === inputId); + expect(target).toBeDefined(); + expect(target!.isOptional).toBe(true); + expect(target!.hasPresentation).toBeDefined(); + expect(target!.hasPresentation![0].id).toBe(vpId); + }); }); From 18cb71719e3501ff1233a63ce3589a6379456c64 Mon Sep 17 00:00:00 2001 From: Maximiliano Osorio Date: Sun, 10 May 2026 14:06:53 -0400 Subject: [PATCH 7/9] test(model-catalog-api): e2e list path stays lean MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /v2.0.0/modelconfigurations (list) keeps the shallow input shape — hasPresentation absent. Confirms divergence between list and by-id field selection. --- src/__tests__/e2e/read-shape-deep-e2e.test.ts | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/src/__tests__/e2e/read-shape-deep-e2e.test.ts b/src/__tests__/e2e/read-shape-deep-e2e.test.ts index c4d81a2..351b772 100644 --- a/src/__tests__/e2e/read-shape-deep-e2e.test.ts +++ b/src/__tests__/e2e/read-shape-deep-e2e.test.ts @@ -153,4 +153,45 @@ describe('read-shape-deep e2e — GET /modelconfigurations/{id}', () => { expect(target!.hasPresentation).toBeDefined(); expect(target!.hasPresentation![0].id).toBe(vpId); }); + + it('list path GET /modelconfigurations does NOT include hasPresentation', async () => { + const softwareId = uniqueId('software'); + const versionId = uniqueId('softwareversion'); + const configId = uniqueId('modelconfiguration'); + const inputId = uniqueId('datasetspecification'); + const vpId = uniqueId('variablepresentation'); + + const post = await inject(app, 'POST', '/v2.0.0/softwares', { + id: softwareId, type: ['Software'], label: ['sw-list-lean'], + hasVersion: [{ id: versionId, type: ['SoftwareVersion'], label: ['v-list-lean'], + hasConfiguration: [{ id: configId, type: ['ModelConfiguration'], label: ['cfg-list-lean'], + hasInput: [{ id: inputId, type: ['DataSetSpecification'], label: ['input-list-lean'], + hasPresentation: [{ id: vpId, type: ['VariablePresentation'], label: ['vp-list-lean'], hasShortName: ['list-lean-vp'] }] }] }] }], + }); + expect(post.statusCode).toBeGreaterThanOrEqual(200); + expect(post.statusCode).toBeLessThan(300); + + trackId('variablepresentations', vpId); + trackId('datasetspecifications', inputId); + trackId('modelconfigurations', configId); + trackId('softwareversions', versionId); + trackId('softwares', softwareId); + + const list = await inject(app, 'GET', '/v2.0.0/modelconfigurations?label=cfg-list-lean&per_page=10'); + expect(list.statusCode).toBe(200); + + type VP = { id: string }; + type DSS = { id: string; label?: string[]; hasPresentation?: VP[] }; + type Cfg = { id: string; hasInput?: DSS[] }; + const rows = list.body as Cfg[]; + expect(Array.isArray(rows)).toBe(true); + + const row = rows.find((r) => r.id === configId); + expect(row).toBeDefined(); + expect(row!.hasInput?.length).toBeGreaterThan(0); + const firstInput = row!.hasInput![0]; + expect(firstInput.id).toBeDefined(); + expect(firstInput.label).toBeDefined(); + expect(firstInput.hasPresentation).toBeUndefined(); + }); }); From b066107a3cdab3f303c5e540f11c543c5da09fcf Mon Sep 17 00:00:00 2001 From: Maximiliano Osorio Date: Sun, 10 May 2026 14:07:13 -0400 Subject: [PATCH 8/9] test(model-catalog-api): e2e empty-array elision on by-id read Config with zero inputs/outputs returns no hasInput/hasOutput keys (response.ts:95). Confirms the deep read does not introduce empty arrays that would diverge from v1.8.0 client contract. --- src/__tests__/e2e/read-shape-deep-e2e.test.ts | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/__tests__/e2e/read-shape-deep-e2e.test.ts b/src/__tests__/e2e/read-shape-deep-e2e.test.ts index 351b772..81f12f0 100644 --- a/src/__tests__/e2e/read-shape-deep-e2e.test.ts +++ b/src/__tests__/e2e/read-shape-deep-e2e.test.ts @@ -194,4 +194,31 @@ describe('read-shape-deep e2e — GET /modelconfigurations/{id}', () => { expect(firstInput.label).toBeDefined(); expect(firstInput.hasPresentation).toBeUndefined(); }); + + it('config with zero inputs/outputs returns no hasInput/hasOutput keys', async () => { + const softwareId = uniqueId('software'); + const versionId = uniqueId('softwareversion'); + const configId = uniqueId('modelconfiguration'); + + const post = await inject(app, 'POST', '/v2.0.0/softwares', { + id: softwareId, type: ['Software'], label: ['sw-empty'], + hasVersion: [{ id: versionId, type: ['SoftwareVersion'], label: ['v-empty'], + hasConfiguration: [{ id: configId, type: ['ModelConfiguration'], label: ['cfg-empty'] }] }], + }); + expect(post.statusCode).toBeGreaterThanOrEqual(200); + expect(post.statusCode).toBeLessThan(300); + + trackId('modelconfigurations', configId); + trackId('softwareversions', versionId); + trackId('softwares', softwareId); + + const get = await inject(app, 'GET', `/v2.0.0/modelconfigurations/${encodeURIComponent(configId)}`); + expect(get.statusCode).toBe(200); + + type Cfg = { id: string; hasInput?: unknown; hasOutput?: unknown }; + const cfg = (Array.isArray(get.body) ? get.body[0] : get.body) as Cfg; + expect(cfg.id).toBe(configId); + expect(cfg.hasInput).toBeUndefined(); + expect(cfg.hasOutput).toBeUndefined(); + }); }); From b12b2ba2d56d0368745b4b05c3741a629b903cb5 Mon Sep 17 00:00:00 2001 From: Maximiliano Osorio Date: Sun, 10 May 2026 14:27:21 -0400 Subject: [PATCH 9/9] fix(model-catalog-api): accept bare-slug {id} on GET/DELETE and custom handlers GET /resources/{id} and DELETE /resources/{id} previously rejected bare slug paths with 400 "Resource ID must be a full URL-encoded URI", forcing callers to URL-encode the full https://w3id.org/okn/i/mint/. Now both paths resolve fullId = isFullUri(id) ? id : `${idPrefix}${id}`, mirroring the existing PUT update behavior. custom-handlers.ts: replaced strict requireFullUri (reject) with resolveResourceId (prepend resource idPrefix). Applied at all 5 call sites covering custom_configurationsetups_id_get, custom_modelconfigurationsetups_id_get, custom_modelconfigurations_id_get, custom_datasetspecifications_get configurationid, and custom_configuration_id_inputs_get. PUT path switched from raw startsWith('https://') to isFullUri for http:// parity. Flipped 3 unit tests that asserted 400 to assert the prepended URI is forwarded to Hasura. --- src/__tests__/integration.test.ts | 65 +++++++++++++++++++++++-------- src/custom-handlers.ts | 37 +++++++++--------- src/service.ts | 20 ++-------- 3 files changed, 69 insertions(+), 53 deletions(-) diff --git a/src/__tests__/integration.test.ts b/src/__tests__/integration.test.ts index 720269e..eabc9a3 100644 --- a/src/__tests__/integration.test.ts +++ b/src/__tests__/integration.test.ts @@ -386,20 +386,32 @@ describe('URI-encoded ID decoding', () => { }) // --------------------------------------------------------------------------- -// Test 9: Plain ID rejected — strict URI mode (bug-086) -// service.ts treats {id} as opaque; plain shortname returns 400. +// Test 9: Plain ID resolved by prepending resource idPrefix +// service.ts accepts a bare slug and prepends resourceConfig.idPrefix before +// querying Hasura. // --------------------------------------------------------------------------- describe('getById with plain ID (no URI prefix)', () => { beforeEach(() => { mockQuery.mockReset() }) - it('returns 400 when id does not start with https:// (strict URI mode)', async () => { - const req = makeReq({ params: { id: '1bade4cb-d924-4253-bfa9-4c02b461396a' } }) + it('prepends idPrefix and queries Hasura with the resolved URI', async () => { + const slug = '1bade4cb-d924-4253-bfa9-4c02b461396a' + mockQuery.mockResolvedValueOnce({ + data: { + modelcatalog_software_by_pk: { + id: `https://w3id.org/okn/i/mint/${slug}`, + label: 'X', + }, + }, + }) + + const req = makeReq({ params: { id: slug } }) const reply = makeReply() await (CatalogService as any).softwares_id_get(req, reply) - expect(reply._status).toBe(400) - expect(mockQuery).not.toHaveBeenCalled() - expect((reply._body as any)?.error).toContain('full URL-encoded URI') + expect(reply._status).toBe(200) + expect(mockQuery).toHaveBeenCalledOnce() + const callArgs = mockQuery.mock.calls[0][0] + expect(callArgs.variables.id).toBe(`https://w3id.org/okn/i/mint/${slug}`) }) }) @@ -409,14 +421,23 @@ describe('getById with plain ID (no URI prefix)', () => { describe('Custom handler URI strict mode', () => { beforeEach(() => { mockQuery.mockReset() }) - it('custom_configurationsetups_id_get returns 400 for plain ID (strict URI mode)', async () => { - const req = makeReq({ params: { id: 'hand_v6' } }) + it('custom_configurationsetups_id_get resolves plain ID with idPrefix', async () => { + const slug = 'hand_v6' + const fullUri = `https://w3id.org/okn/i/mint/${slug}` + mockQuery.mockResolvedValueOnce({ + data: { + modelcatalog_configuration_by_pk: { id: fullUri, label: slug }, + }, + }) + + const req = makeReq({ params: { id: slug } }) const reply = makeReply() await customHandlers.custom_configurationsetups_id_get(req, reply) - expect(reply._status).toBe(400) - expect(mockQuery).not.toHaveBeenCalled() - expect((reply._body as any)?.error).toContain('full URL-encoded URI') + expect(reply._status).toBe(200) + expect(mockQuery).toHaveBeenCalledOnce() + const callArgs = mockQuery.mock.calls[0][0] + expect(callArgs.variables.id).toBe(fullUri) }) it('custom_modelconfigurationsetups_id_get passes full URI unchanged', async () => { @@ -440,14 +461,24 @@ describe('Custom handler URI strict mode', () => { expect(callArgs.variables.id).toBe(fullUri) }) - it('custom_datasetspecifications_get returns 400 for plain configurationid (strict URI mode)', async () => { - const req = makeReq({ query: { configurationid: 'some-config-uuid' } }) + it('custom_datasetspecifications_get resolves plain configurationid with idPrefix', async () => { + const slug = 'some-config-uuid' + const fullCfgUri = `https://w3id.org/okn/i/mint/${slug}` + mockQuery.mockResolvedValueOnce({ + data: { + modelcatalog_configuration_input: [], + modelcatalog_configuration_output: [], + }, + }) + + const req = makeReq({ query: { configurationid: slug } }) const reply = makeReply() await customHandlers.custom_datasetspecifications_get(req, reply) - expect(reply._status).toBe(400) - expect(mockQuery).not.toHaveBeenCalled() - expect((reply._body as any)?.error).toContain('full URL-encoded URI') + expect(reply._status).toBe(200) + expect(mockQuery).toHaveBeenCalledOnce() + const callArgs = mockQuery.mock.calls[0][0] + expect(callArgs.variables.cfgId).toBe(fullCfgUri) }) it('custom_datasetspecifications_get WHERE clause uses configuration_id not model_configuration_id', async () => { diff --git a/src/custom-handlers.ts b/src/custom-handlers.ts index b179225..78a8a89 100644 --- a/src/custom-handlers.ts +++ b/src/custom-handlers.ts @@ -20,16 +20,16 @@ import { getResourceConfig } from './mappers/resource-registry.js'; import { Apps } from '@tapis/tapis-typescript'; /** - * Reject IDs that are not full URIs (https:// or http://). Returns true when - * the id is acceptable; otherwise sends a 400 reply and returns false. + * Resolve an incoming {id} path/query value to a full resource URI. + * If already absolute (https:// or http://), return as-is; otherwise + * prepend the resource's configured idPrefix to the bare slug. */ -function requireFullUri(reply: any, id: string): boolean { - if (id.startsWith('https://') || id.startsWith('http://')) return true; - reply.code(400).send({ - error: 'Resource ID must be a full URL-encoded URI', - hint: `Got "${id}". Pass URL-encoded full URI.`, - }); - return false; +function resolveResourceId( + id: string, + resourceConfig: { idPrefix: string }, +): string { + if (id.startsWith('https://') || id.startsWith('http://')) return id; + return `${resourceConfig.idPrefix}${id}`; } // --------------------------------------------------------------------------- @@ -432,8 +432,7 @@ async function custom_modelconfigurationsetups_variable_get( async function custom_configurationsetups_id_get(req: any, reply: any) { const resourceConfig = getResourceConfig('configurationsetups')!; const id = decodeURIComponent(req.params.id); - if (!requireFullUri(reply, id)) return; - const fullId = id; + const fullId = resolveResourceId(id, resourceConfig); const queryStr = ` query CustomConfigurationSetupById($id: String!) { @@ -474,8 +473,7 @@ async function custom_configurationsetups_id_get(req: any, reply: any) { async function custom_modelconfigurationsetups_id_get(req: any, reply: any) { const resourceConfig = getResourceConfig('modelconfigurationsetups')!; const id = decodeURIComponent(req.params.id); - if (!requireFullUri(reply, id)) return; - const fullId = id; + const fullId = resolveResourceId(id, resourceConfig); const queryStr = ` query CustomModelConfigurationSetupById($id: String!) { @@ -516,8 +514,7 @@ async function custom_modelconfigurationsetups_id_get(req: any, reply: any) { async function custom_modelconfigurations_id_get(req: any, reply: any) { const resourceConfig = getResourceConfig('modelconfigurations')!; const id = decodeURIComponent(req.params.id); - if (!requireFullUri(reply, id)) return; - const fullId = id; + const fullId = resolveResourceId(id, resourceConfig); const queryStr = ` query CustomModelConfigurationById($id: String!) { @@ -632,8 +629,9 @@ async function custom_datasetspecifications_get(req: any, reply: any) { if (configurationid) { const cfgId = decodeURIComponent(configurationid); - if (!requireFullUri(reply, cfgId)) return; - const fullCfgId = cfgId; + const fullCfgId = resolveResourceId(cfgId, { + idPrefix: getResourceConfig('modelconfigurations')!.idPrefix, + }); const innerVars: Record = { cfgId: fullCfgId }; // Query junction tables directly; relationship names on junction rows are `input` and `output` @@ -727,8 +725,9 @@ async function custom_datasetspecifications_get(req: any, reply: any) { async function custom_configuration_id_inputs_get(req: any, reply: any) { const resourceConfig = getResourceConfig('datasetspecifications')!; const id = decodeURIComponent(req.params.id); - if (!requireFullUri(reply, id)) return; - const fullId = id; + const fullId = resolveResourceId(id, { + idPrefix: getResourceConfig('modelconfigurations')!.idPrefix, + }); const queryStr = ` query CustomConfigurationInputs($id: String!) { diff --git a/src/service.ts b/src/service.ts index 35b94f4..cd988b2 100644 --- a/src/service.ts +++ b/src/service.ts @@ -172,14 +172,7 @@ class CatalogServiceImpl { } const id = decodeURIComponent(req.params.id) - if (!id.startsWith('https://') && !id.startsWith('http://')) { - reply.code(400).send({ - error: 'Resource ID must be a full URL-encoded URI', - hint: `Got "${id}". Pass URL-encoded full URI, e.g. /${resource}/${encodeURIComponent(resourceConfig.idPrefix + id)}`, - }) - return - } - const fullId = id + const fullId = isFullUri(id) ? id : `${resourceConfig.idPrefix}${id}` const fields = getFieldSelection(resourceConfig.hasuraTable!, 'byId') const tableSuffix = resourceConfig.hasuraTable.replace('modelcatalog_', '') @@ -304,7 +297,7 @@ class CatalogServiceImpl { } const id = decodeURIComponent(req.params.id) - const fullId = id.startsWith('https://') ? id : `${resourceConfig.idPrefix}${id}` + const fullId = isFullUri(id) ? id : `${resourceConfig.idPrefix}${id}` const body = { ...(req.body || {}), id: fullId } let tree @@ -364,14 +357,7 @@ class CatalogServiceImpl { } const id = decodeURIComponent(req.params.id) - if (!id.startsWith('https://') && !id.startsWith('http://')) { - reply.code(400).send({ - error: 'Resource ID must be a full URL-encoded URI', - hint: `Got "${id}". Pass URL-encoded full URI, e.g. /${resource}/${encodeURIComponent(resourceConfig.idPrefix + id)}`, - }) - return - } - const fullId = id + const fullId = isFullUri(id) ? id : `${resourceConfig.idPrefix}${id}` const tableSuffix = resourceConfig.hasuraTable.replace('modelcatalog_', '') const mutationStr = ` mutation DeleteMutation($id: String!) {