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..81f12f0 --- /dev/null +++ b/src/__tests__/e2e/read-shape-deep-e2e.test.ts @@ -0,0 +1,224 @@ +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])); + }); + + 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); + }); + + 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(); + }); + + 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(); + }); +}); 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/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..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 @@ -548,10 +553,185 @@ 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 = { + // ========================================================================= + // 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(), +}; + /** * 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'; } diff --git a/src/service.ts b/src/service.ts index 2eca21f..cd988b2 100644 --- a/src/service.ts +++ b/src/service.ts @@ -172,15 +172,8 @@ 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 fields = getFieldSelection(resourceConfig.hasuraTable!) + const fullId = isFullUri(id) ? id : `${resourceConfig.idPrefix}${id}` + const fields = getFieldSelection(resourceConfig.hasuraTable!, 'byId') const tableSuffix = resourceConfig.hasuraTable.replace('modelcatalog_', '') const queryStr = ` @@ -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!) {