From 5ace0378189a9ea1fa2a2e67a6106e33f201e2cf Mon Sep 17 00:00:00 2001 From: Maximiliano Osorio Date: Mon, 27 Apr 2026 21:44:53 -0400 Subject: [PATCH 1/8] feat(12-02): add junctionColumns to RelationshipConfig and hasInput entries - Add junctionColumns optional field to RelationshipConfig interface - Set junctionColumns: { is_optional: isOptional } on modelconfigurations.hasInput - Set junctionColumns: { is_optional: isOptional } on modelconfigurationsetups.hasInput - Set junctionColumns: { is_optional: isOptional } on configurationsetups.hasInput alias --- src/mappers/resource-registry.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/mappers/resource-registry.ts b/src/mappers/resource-registry.ts index 8647d9d..b590a36 100644 --- a/src/mappers/resource-registry.ts +++ b/src/mappers/resource-registry.ts @@ -24,6 +24,12 @@ export interface RelationshipConfig { parentFkColumn?: string; /** The API resource name of the target type (for nested transforms) */ targetResource: string; + /** + * Extra scalar columns stored on the junction row itself (beyond the two FK columns). + * Used by the PUT path in service.ts to pass per-row extra data. + * Map from junction column name (snake_case) to the corresponding camelCase key in the request body item. + */ + junctionColumns?: Record; } export interface ResourceConfig { @@ -206,6 +212,7 @@ export const RESOURCE_REGISTRY: Record = { junctionRelName: 'input', parentFkColumn: 'configuration_id', targetResource: 'datasetspecifications', + junctionColumns: { is_optional: 'isOptional' }, }, hasOutput: { hasuraRelName: 'outputs', @@ -290,6 +297,7 @@ export const RESOURCE_REGISTRY: Record = { junctionRelName: 'input', parentFkColumn: 'configuration_id', targetResource: 'datasetspecifications', + junctionColumns: { is_optional: 'isOptional' }, }, hasOutput: { hasuraRelName: 'outputs', @@ -367,6 +375,7 @@ export const RESOURCE_REGISTRY: Record = { junctionRelName: 'input', parentFkColumn: 'configuration_id', targetResource: 'datasetspecifications', + junctionColumns: { is_optional: 'isOptional' }, }, hasOutput: { hasuraRelName: 'outputs', From e3767aea6330b6f8cd80fbbaa555b286228c43c0 Mon Sep 17 00:00:00 2001 From: Maximiliano Osorio Date: Mon, 27 Apr 2026 21:45:37 -0400 Subject: [PATCH 2/8] feat(12-02): propagate is_optional through field-maps, service PUT and request CREATE paths - field-maps.ts: add is_optional at junction row level (sibling of input {}) - service.ts: spread junctionColumns onto junction rows in PUT delete-then-insert path - request.ts: spread junctionColumns onto outer junction row in buildJunctionInserts CREATE path --- src/hasura/field-maps.ts | 1 + src/mappers/request.ts | 8 +++++++- src/service.ts | 8 +++++++- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/hasura/field-maps.ts b/src/hasura/field-maps.ts index fdd2eaf..6156a1e 100644 --- a/src/hasura/field-maps.ts +++ b/src/hasura/field-maps.ts @@ -214,6 +214,7 @@ child_configurations { description } inputs { + is_optional input { id label diff --git a/src/mappers/request.ts b/src/mappers/request.ts index 08454e5..a0f87fc 100644 --- a/src/mappers/request.ts +++ b/src/mappers/request.ts @@ -213,7 +213,7 @@ export function buildJunctionInserts( } } - return { + const junctionRow: Record = { [relConfig.junctionRelName!]: { data: nestedData, on_conflict: { @@ -222,6 +222,12 @@ export function buildJunctionInserts( }, }, }; + if (relConfig.junctionColumns) { + for (const [colName, camelKey] of Object.entries(relConfig.junctionColumns)) { + if (item[camelKey] !== undefined) junctionRow[colName] = item[camelKey]; + } + } + return junctionRow; }), on_conflict: { constraint: `${relConfig.junctionTable}_pkey`, diff --git a/src/service.ts b/src/service.ts index 913040a..9ef307f 100644 --- a/src/service.ts +++ b/src/service.ts @@ -304,10 +304,16 @@ class CatalogServiceImpl { const targetId = rawItemId ? rawItemId.startsWith('https://') ? rawItemId : `${ID_PREFIX}${rawItemId}` : `${ID_PREFIX}${randomUUID()}` - return { + const row: Record = { [relConfig.parentFkColumn!]: fullId, [targetFkColumn]: targetId, } + if (relConfig.junctionColumns) { + for (const [colName, camelKey] of Object.entries(relConfig.junctionColumns)) { + if (item[camelKey] !== undefined) row[colName] = item[camelKey] + } + } + return row }) const juncSuffix2 = relConfig.junctionTable.replace('modelcatalog_', '') From da2329da80d7294e58911610b2fd9f449f424d3b Mon Sep 17 00:00:00 2001 From: Maximiliano Osorio Date: Mon, 27 Apr 2026 21:46:34 -0400 Subject: [PATCH 3/8] test(12-02): add buildJunctionInserts tests for configuration_input is_optional (D-21, D-22) - Test 10: isOptional=true flows to is_optional on outer junction row (D-21) - Test 11: absent isOptional produces no is_optional key on junction row (D-22) - Fix: exclude junctionColumns camelCase keys from nested entity data copy loop to prevent isOptional leaking into the nested dataset_specification insert --- src/mappers/__tests__/request.test.ts | 37 +++++++++++++++++++++++++++ src/mappers/request.ts | 8 +++++- 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/src/mappers/__tests__/request.test.ts b/src/mappers/__tests__/request.test.ts index d19c6f5..c534046 100644 --- a/src/mappers/__tests__/request.test.ts +++ b/src/mappers/__tests__/request.test.ts @@ -321,4 +321,41 @@ describe('buildJunctionInserts', () => { expect(personData).toHaveProperty('first_name', 'John'); expect(personData).toHaveProperty('last_name', 'Doe'); }); + + it('Test 10: spreads is_optional=true from isOptional onto configuration_input junction row (D-21)', () => { + const configConfig = getResourceConfig('modelconfigurations')!; + const body = { + hasInput: [ + { id: 'https://w3id.org/okn/i/mint/SomeDataset', isOptional: true } + ], + }; + const result = buildJunctionInserts(body, configConfig); + const inputs = result['inputs'] as Record; + expect(inputs).toHaveProperty('data'); + const data = inputs['data'] as Record[]; + expect(data).toHaveLength(1); + const junctionRow = data[0] as Record; + // is_optional lives on the outer junction row (modelcatalog_configuration_input), + // NOT inside junctionRow['input']['data']. Verify it is at the top level: + expect(junctionRow).toHaveProperty('is_optional', true); + // Nested entity data should NOT have is_optional: + const nestedData = (junctionRow['input'] as Record)['data'] as Record; + expect(nestedData).not.toHaveProperty('is_optional'); + }); + + it('Test 11: omits is_optional from junction row when isOptional is absent in request body (D-22)', () => { + const configConfig = getResourceConfig('modelconfigurations')!; + const body = { + hasInput: [ + { id: 'https://w3id.org/okn/i/mint/SomeDataset' } + ], + }; + const result = buildJunctionInserts(body, configConfig); + const inputs = result['inputs'] as Record; + const data = inputs['data'] as Record[]; + const junctionRow = data[0] as Record; + // When isOptional is not provided, the key should be absent (not defaulted to false) + // so that Postgres applies its own column default: + expect(junctionRow).not.toHaveProperty('is_optional'); + }); }); diff --git a/src/mappers/request.ts b/src/mappers/request.ts index a0f87fc..3e3200d 100644 --- a/src/mappers/request.ts +++ b/src/mappers/request.ts @@ -196,10 +196,16 @@ export function buildJunctionInserts( nestedData['id'] = `${ID_PREFIX}${randomUUID()}`; } + // Build set of camelCase keys that belong to the junction row itself (not the nested entity) + const junctionCamelKeys = new Set( + relConfig.junctionColumns ? Object.values(relConfig.junctionColumns) : [] + ); + // Copy scalar fields from nested object (camelCase -> snake_case) - // Skip 'id' (already handled), 'type' (not stored) + // Skip 'id' (already handled), 'type' (not stored), and junction-row-level fields for (const [key, value] of Object.entries(item)) { if (key === 'id' || key === 'type') continue; + if (junctionCamelKeys.has(key)) continue; // junction column — goes on outer row, not nested entity const snakeKey = camelToSnake(key); const unwrapped = Array.isArray(value) ? value.length === 1 From 72ec181eb4314bd4d1980a1c939eba17075184d9 Mon Sep 17 00:00:00 2001 From: Maximiliano Osorio Date: Mon, 27 Apr 2026 21:47:13 -0400 Subject: [PATCH 4/8] feat(12-02): add isOptional boolean field to DatasetSpecification schema in openapi.yaml - Declare isOptional as nullable boolean on DatasetSpecification - Field represents junction-row metadata surfaced on hasInput items --- openapi.yaml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/openapi.yaml b/openapi.yaml index 12ec498..9ed95cd 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -14028,6 +14028,13 @@ components: type: integer nullable: true type: array + isOptional: + description: >- + When true, this input is optional for the configuration. + Ensemble manager will skip it during Tapis job submission if no dataset + is bound, rather than failing with an error. + nullable: true + type: boolean id: description: identifier nullable: false From 7109442c06a39f9fe2bbe152a21c41750dc464b4 Mon Sep 17 00:00:00 2001 From: Maximiliano Osorio Date: Tue, 28 Apr 2026 19:22:45 -0400 Subject: [PATCH 5/8] ci: widen branches to '**' and tag with SAFE_BRANCH so feature branches build CI was hardcoded to refs/heads/main, so branches like gsd/phase-12-is-optional never produced a docker image. Mirrors the ui repo fix (e0547dd): - branches: '**' so slashed feature branches trigger - build-and-push fires on any push event - SAFE_BRANCH replaces '/' with '-' (Docker tag spec forbids '/') - :latest gated to a separate post-build retag job that only fires on main --- .github/workflows/ci.yml | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 263884b..31db10e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,7 +1,7 @@ name: CI on: push: - branches: [main] + branches: ['**'] pull_request: branches: [main] @@ -20,13 +20,18 @@ jobs: build-and-push: needs: test - if: github.event_name == 'push' && github.ref == 'refs/heads/main' + if: github.event_name == 'push' runs-on: ubuntu-latest permissions: contents: read packages: write steps: - uses: actions/checkout@v4 + - name: Compute sanitized branch tag + run: | + SAFE_BRANCH="${GITHUB_REF_NAME}" + # Docker tag spec forbids '/'; replace with '-' so slash-containing branches build + echo "SAFE_BRANCH=${SAFE_BRANCH//\//-}" >> $GITHUB_ENV - uses: docker/setup-buildx-action@v3 - uses: docker/login-action@v3 with: @@ -38,6 +43,25 @@ jobs: push: true tags: | ghcr.io/mintproject/model-catalog-api:${{ github.sha }} - ghcr.io/mintproject/model-catalog-api:latest + ghcr.io/mintproject/model-catalog-api:${{ env.SAFE_BRANCH }} cache-from: type=gha cache-to: type=gha,mode=max + + tag-latest: + needs: build-and-push + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Tag commit SHA image as latest + run: | + docker buildx imagetools create \ + --tag ghcr.io/mintproject/model-catalog-api:latest \ + ghcr.io/mintproject/model-catalog-api:${{ github.sha }} From 9bcbecdd44f7c4385d3c28ccab8105def9c357cb Mon Sep 17 00:00:00 2001 From: Maximiliano Osorio Date: Tue, 28 Apr 2026 19:35:15 -0400 Subject: [PATCH 6/8] fix(12-05): hoist junction columns onto nested entity in response mapper GET /modelconfigurations/{id} dropped is_optional from hasInput[] because transformRow's junction traversal pivoted into the nested target entity (DSpec) and discarded outer junction-row scalars. Hoist any column listed in relConfig.junctionColumns onto the transformed entity (array-wrapped per v1.8.0 contract) so writes round-trip through GET. --- src/mappers/response.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/mappers/response.ts b/src/mappers/response.ts index 3b6a135..53fb414 100644 --- a/src/mappers/response.ts +++ b/src/mappers/response.ts @@ -98,14 +98,24 @@ export function transformRow( // If this relationship goes through a junction table, each item is a junction row. // The actual target entity is nested under junctionRelName inside the junction row. const junctionRelName = relConfig.junctionRelName; + const junctionColumnNames = relConfig.junctionColumns + ? Object.keys(relConfig.junctionColumns) + : []; const transformed = relArray .map((item) => { - // Junction traversal: extract the nested target entity if junctionRelName is set - // and the nested entity exists as a key in the junction row. const targetRow = (junctionRelName && item[junctionRelName] != null) ? item[junctionRelName] as Record : item; - return transformRow(targetRow, targetConfig, depth + 1); + const out = transformRow(targetRow, targetConfig, depth + 1); + // Hoist junction-row scalar columns (e.g. is_optional) onto the + // transformed nested entity so they survive junction traversal. + for (const colName of junctionColumnNames) { + const val = item[colName]; + if (val !== null && val !== undefined) { + out[snakeToCamel(colName)] = [val]; + } + } + return out; }) .filter((item) => item['id'] !== null && item['id'] !== undefined); if (transformed.length > 0) { From 213bc6c9130e049a673adc847613fb0e87952568 Mon Sep 17 00:00:00 2001 From: Maximiliano Osorio Date: Tue, 28 Apr 2026 20:12:04 -0400 Subject: [PATCH 7/8] fix(12-05): hoist junction columns as scalar, not array, in response mapper Junction column values like is_optional (bool) were wrapped in a single-element array, mirroring v1.8.0's array-wrapped object property convention. That convention is for relationship/object fields, not for plain junction scalars. The wrap broke the UI: !![false] is true (any array is truthy), so the isOptional checkbox flipped to checked on every refresh regardless of the DB value. Emit junction column hoist as scalar so the SDK FromJSONTyped patch copies it through to ds.isOptional verbatim and the UI sees the real boolean. Test: response.test.ts now asserts isOptional is scalar (not array) for both true and false junction column values, and omits the field when the column is null. --- src/mappers/__tests__/response.test.ts | 54 ++++++++++++++++++++++++++ src/mappers/response.ts | 5 ++- 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/src/mappers/__tests__/response.test.ts b/src/mappers/__tests__/response.test.ts index 300d424..70a0993 100644 --- a/src/mappers/__tests__/response.test.ts +++ b/src/mappers/__tests__/response.test.ts @@ -194,6 +194,60 @@ describe('transformRow - nested related objects', () => { }); }); +describe('transformRow - junction column hoist (D-21 round-trip)', () => { + const configurationConfig = RESOURCE_REGISTRY['modelconfigurations']!; + + it('hoists is_optional from junction row onto nested DSpec as scalar (not array)', () => { + const row = { + id: 'https://w3id.org/okn/i/mint/Cfg1', + label: 'Cfg1', + // GraphQL traversal: configuration -> inputs[] (junction rows) -> input (DSpec) + inputs: [ + { + is_optional: false, + input: { + id: 'https://w3id.org/okn/i/mint/Dspec1', + label: 'Dspec1', + }, + }, + { + is_optional: true, + input: { + id: 'https://w3id.org/okn/i/mint/Dspec2', + label: 'Dspec2', + }, + }, + ], + }; + const result = transformRow(row, configurationConfig); + const hasInput = result['hasInput'] as Array>; + + expect(Array.isArray(hasInput)).toBe(true); + expect(hasInput).toHaveLength(2); + + // Junction column hoisted as scalar — array wrapping makes UI coerce + // !![false] to true on refresh and the checkbox lies. + expect(hasInput[0]!['isOptional']).toBe(false); + expect(hasInput[1]!['isOptional']).toBe(true); + expect(Array.isArray(hasInput[0]!['isOptional'])).toBe(false); + }); + + it('omits isOptional when junction column is null', () => { + const row = { + id: 'https://w3id.org/okn/i/mint/Cfg1', + inputs: [ + { + is_optional: null, + input: { id: 'https://w3id.org/okn/i/mint/Dspec1' }, + }, + ], + }; + const result = transformRow(row, configurationConfig); + const hasInput = result['hasInput'] as Array>; + expect(hasInput[0]).not.toHaveProperty('isOptional'); + }); +}); + describe('transformList', () => { it('maps transformRow over an array of rows', () => { const rows = [ diff --git a/src/mappers/response.ts b/src/mappers/response.ts index 53fb414..99cf349 100644 --- a/src/mappers/response.ts +++ b/src/mappers/response.ts @@ -109,10 +109,13 @@ export function transformRow( const out = transformRow(targetRow, targetConfig, depth + 1); // Hoist junction-row scalar columns (e.g. is_optional) onto the // transformed nested entity so they survive junction traversal. + // Emit as scalar (not array): these are junction column values like + // bool/text, not v1.8.0-style array-wrapped object properties. Array + // wrapping makes the UI coerce !![false] -> true on refresh. for (const colName of junctionColumnNames) { const val = item[colName]; if (val !== null && val !== undefined) { - out[snakeToCamel(colName)] = [val]; + out[snakeToCamel(colName)] = val; } } return out; From b3e090513d451929856246c903cac7f8e1430bc6 Mon Sep 17 00:00:00 2001 From: Maximiliano Osorio Date: Fri, 1 May 2026 19:39:59 -0400 Subject: [PATCH 8/8] feat(tapis): add /tapis/{tenant}/apps proxy endpoints Migrate Tapis app listing and lookup from legacy model-catalog-fastapi. Uses @tapis/tapis-typescript SDK; forwards Bearer JWT as X-Tapis-Token. --- openapi.yaml | 137 +++++++++++++++++++++++++++++++++++++++++ package-lock.json | 100 ++++++++++++++++++++++++++++++ package.json | 1 + src/custom-handlers.ts | 84 ++++++++++++++++++++++++- 4 files changed, 321 insertions(+), 1 deletion(-) diff --git a/openapi.yaml b/openapi.yaml index 9ed95cd..7da3d65 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -9372,6 +9372,76 @@ paths: tags: - DatasetSpecification x-oba-custom: true + /tapis/{tenant}/apps: + get: + operationId: custom_tapis_apps_get + description: List Tapis applications for the given tenant. + parameters: + - description: Tapis tenant identifier + explode: false + in: path + name: tenant + required: true + schema: + type: string + style: simple + responses: + "200": + content: + application/json: + schema: + items: + $ref: "#/components/schemas/TapisApp" + type: array + description: Successful response - returns an array with the instances of TapisApp. + summary: List all instances of TapisApp + tags: + - TapisApp + security: + - BearerAuth: [] + x-oba-custom: true + /tapis/{tenant}/apps/{app_id}/{app_version}: + get: + operationId: custom_tapis_apps_id_get + description: Get the details of a single Tapis application by id and version. + parameters: + - description: Tapis tenant identifier + explode: false + in: path + name: tenant + required: true + schema: + type: string + style: simple + - description: Tapis application id + explode: false + in: path + name: app_id + required: true + schema: + type: string + style: simple + - description: Tapis application version + explode: false + in: path + name: app_version + required: true + schema: + type: string + style: simple + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/TapisApp" + description: Successful response - returns an instance of TapisApp. + summary: Get a single TapisApp by its id + tags: + - TapisApp + security: + - BearerAuth: [] + x-oba-custom: true components: schemas: Intervention: @@ -17564,6 +17634,73 @@ components: - $ref: "#/components/schemas/GeoCoordinates" - $ref: "#/components/schemas/GeoShape" title: Region_geo_inner + TapisApp: + description: Tapis application (proxied from Tapis v3 /apps endpoint). + type: object + properties: + tenant: + type: string + id: + type: string + version: + type: string + description: + type: string + owner: + type: string + enabled: + type: boolean + versionEnabled: + type: boolean + locked: + type: boolean + isPublic: + type: boolean + sharedWithUsers: + type: array + items: + type: string + runtime: + type: string + runtimeVersion: + type: string + runtimeOptions: + type: array + items: + type: string + containerImage: + type: string + jobType: + type: string + maxJobs: + type: integer + maxJobsPerUser: + type: integer + strictFileInputs: + type: boolean + jobAttributes: + type: object + additionalProperties: true + tags: + type: array + items: + type: string + notes: + type: object + additionalProperties: true + sharedAppCtx: + type: string + uuid: + type: string + deleted: + type: boolean + created: + type: string + format: date-time + updated: + type: string + format: date-time + title: TapisApp securitySchemes: BearerAuth: bearerFormat: JWT diff --git a/package-lock.json b/package-lock.json index 6a4e9ec..7749653 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@fastify/jwt": "^10.0.0", "@fastify/swagger": "^9.7.0", "@fastify/swagger-ui": "^5.2.5", + "@tapis/tapis-typescript": "^0.0.66", "fastify": "^5.7.4", "fastify-openapi-glue": "^4.10.2", "graphql": "^16.12.0" @@ -3069,6 +3070,99 @@ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", "dev": true }, + "node_modules/@tapis/tapis-typescript": { + "version": "0.0.66", + "resolved": "https://registry.npmjs.org/@tapis/tapis-typescript/-/tapis-typescript-0.0.66.tgz", + "integrity": "sha512-musPci9WMzTj4PsZrVdkuLdCjNKJ6qV8AqH6gln1a5WgQR6meQvLz8LuEg1p6S+QGoDmlqkU+7T4T9YHV0Wudg==", + "license": "ISC", + "dependencies": { + "@tapis/tapis-typescript-actors": "^0.0.3", + "@tapis/tapis-typescript-apps": "^0.0.9", + "@tapis/tapis-typescript-authenticator": "^1.8.4", + "@tapis/tapis-typescript-files": "^0.0.4", + "@tapis/tapis-typescript-jobs": "^0.0.6", + "@tapis/tapis-typescript-mlhub-datasets": "^0.0.1", + "@tapis/tapis-typescript-mlhub-models": "^0.0.2", + "@tapis/tapis-typescript-notifications": "^0.0.1", + "@tapis/tapis-typescript-pods": "^0.0.13", + "@tapis/tapis-typescript-sk": "^0.0.3", + "@tapis/tapis-typescript-streams": "^0.0.4", + "@tapis/tapis-typescript-systems": "^0.0.7", + "@tapis/tapis-typescript-tenants": "^0.0.2", + "@tapis/tapis-typescript-workflows": "1.9.0-a3", + "@types/mocha": "^9.0.0" + } + }, + "node_modules/@tapis/tapis-typescript-actors": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@tapis/tapis-typescript-actors/-/tapis-typescript-actors-0.0.3.tgz", + "integrity": "sha512-Dqadg5bJqZ1QfEp+/YpBxfeoTNtItn05ML924jtba+mb7GX0m7i8/v9UHaMEpSEwzRIVNFBcAA1IKvHYBtHqZg==" + }, + "node_modules/@tapis/tapis-typescript-apps": { + "version": "0.0.9", + "resolved": "https://registry.npmjs.org/@tapis/tapis-typescript-apps/-/tapis-typescript-apps-0.0.9.tgz", + "integrity": "sha512-wwsYqXHBGVqnWxfDumNSPLnvGVlV3XZ0dmu/xd3pKCsu4dGmCQL2K7kh/k4V9Gf7K2+QEQW7pV30gz0Ygcn+UA==" + }, + "node_modules/@tapis/tapis-typescript-authenticator": { + "version": "1.8.4", + "resolved": "https://registry.npmjs.org/@tapis/tapis-typescript-authenticator/-/tapis-typescript-authenticator-1.8.4.tgz", + "integrity": "sha512-B8YH/9QnVCoTHvrC8nrHOngluNicAn4yKp02AWhpZrSCDoFAH0kY+U444NUYIb4h+JAcOrk73IPw+ZTEFofdVg==" + }, + "node_modules/@tapis/tapis-typescript-files": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/@tapis/tapis-typescript-files/-/tapis-typescript-files-0.0.4.tgz", + "integrity": "sha512-Zdft3GmB9friLrEazXfY5bxy9KaDD/24vvO2Xd5Z4AQ0rIhL5zhGY/OhRmDgvT8yISAwDITGkpeZYpE9ZT8dbg==" + }, + "node_modules/@tapis/tapis-typescript-jobs": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@tapis/tapis-typescript-jobs/-/tapis-typescript-jobs-0.0.6.tgz", + "integrity": "sha512-tCXMQvpPdUZoXIAL53EWQqb/99T7LAcnfdLkPmwhna4VyKaNLPQEBRIbSc8PDTZLREDhrnIsGqg0N1PK6ITicA==" + }, + "node_modules/@tapis/tapis-typescript-mlhub-datasets": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@tapis/tapis-typescript-mlhub-datasets/-/tapis-typescript-mlhub-datasets-0.0.1.tgz", + "integrity": "sha512-5tw5iISGQpCvbMXh0TRMwJFn9JGe7R/MapCHWnglr0YBq0uj3Jp8wXQAl6wAy0yybQdLNqFQn9LbvJhqIjnjQQ==" + }, + "node_modules/@tapis/tapis-typescript-mlhub-models": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@tapis/tapis-typescript-mlhub-models/-/tapis-typescript-mlhub-models-0.0.2.tgz", + "integrity": "sha512-Yro3YTyEWhNLvTD0r+1KERCoeK5XI7Sa2BseUZxNGxrkLsA6S0jfUDR1P0pNjkMQS7UuLrSUjK9V6BcoVLSJpQ==" + }, + "node_modules/@tapis/tapis-typescript-notifications": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@tapis/tapis-typescript-notifications/-/tapis-typescript-notifications-0.0.1.tgz", + "integrity": "sha512-hCzEx0N0AHSOK6bzyaqoKURQv8DzXFXnt5KDHrWyflj/4S7T2/0zu+ruxj3n0ATvZuZnvKBJH/0rAowLCscc5g==" + }, + "node_modules/@tapis/tapis-typescript-pods": { + "version": "0.0.13", + "resolved": "https://registry.npmjs.org/@tapis/tapis-typescript-pods/-/tapis-typescript-pods-0.0.13.tgz", + "integrity": "sha512-puqE3A2lOSl2qg9qRvbQUjmbIm7ZSrI9egskFXnPU9pppGMJRtMeGJ4NRDoaULkc/397r6Rza0kuTPaclQnAnw==" + }, + "node_modules/@tapis/tapis-typescript-sk": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@tapis/tapis-typescript-sk/-/tapis-typescript-sk-0.0.3.tgz", + "integrity": "sha512-R2lT5VcT8Cp1f/YPvPeg178OAT5FfhpMiEVcz18AX1iC+1+cx0iJnCecO5cXtabLrbobw7WST8zK9fKS+QWH1w==" + }, + "node_modules/@tapis/tapis-typescript-streams": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/@tapis/tapis-typescript-streams/-/tapis-typescript-streams-0.0.4.tgz", + "integrity": "sha512-9jEOWo5Ryi+w8AUEh+APDz1cfHEAMJxZdYOLNpyGHnKCLg4KFEfYqr6vbeHXE9MeW/VSoXODTfQSTpmI6LjYKA==" + }, + "node_modules/@tapis/tapis-typescript-systems": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/@tapis/tapis-typescript-systems/-/tapis-typescript-systems-0.0.7.tgz", + "integrity": "sha512-Sjvn1LrZBobVshOeuzOtVtIYgeRztxMZ8paOD47HI4GzZM1+8X7ZJ+Uae9BbQAd8gsGDlPuqe6Sll0plWaqFyg==" + }, + "node_modules/@tapis/tapis-typescript-tenants": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@tapis/tapis-typescript-tenants/-/tapis-typescript-tenants-0.0.2.tgz", + "integrity": "sha512-CebqVmJ41evXC/gYj+HphU0KHveXIPjVnjWdS7ABQpIWmvYp2xyIW4jAzhaB2GXmDaUcc+KHS+HCJBh82A6nOA==" + }, + "node_modules/@tapis/tapis-typescript-workflows": { + "version": "1.9.0-a3", + "resolved": "https://registry.npmjs.org/@tapis/tapis-typescript-workflows/-/tapis-typescript-workflows-1.9.0-a3.tgz", + "integrity": "sha512-AIsZRNmG0TwRwL0P55l41m09t7doxKi2Mq++joILYzRd2vxHXgyRw1zV8I1sbZ/H7vH1Fb1qz4AkLYuErwwhSQ==" + }, "node_modules/@theguild/federation-composition": { "version": "0.21.3", "resolved": "https://registry.npmjs.org/@theguild/federation-composition/-/federation-composition-0.21.3.tgz", @@ -3109,6 +3203,12 @@ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "dev": true }, + "node_modules/@types/mocha": { + "version": "9.1.1", + "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-9.1.1.tgz", + "integrity": "sha512-Z61JK7DKDtdKTWwLeElSEBcWGRLY8g95ic5FoQqI9CMx0ns/Ghep3B4DfcEimiKMvtamNVULVNKEsiwV3aQmXw==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "25.3.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.0.tgz", diff --git a/package.json b/package.json index cbd8029..d5d10ea 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "@fastify/jwt": "^10.0.0", "@fastify/swagger": "^9.7.0", "@fastify/swagger-ui": "^5.2.5", + "@tapis/tapis-typescript": "^0.0.66", "fastify": "^5.7.4", "fastify-openapi-glue": "^4.10.2", "graphql": "^16.12.0" diff --git a/src/custom-handlers.ts b/src/custom-handlers.ts index 1a11952..cb0a3a2 100644 --- a/src/custom-handlers.ts +++ b/src/custom-handlers.ts @@ -17,6 +17,7 @@ import { readClient, gql } from './hasura/client.js' import { transformRow, transformList } from './mappers/response.js' import { getResourceConfig } from './mappers/resource-registry.js' +import { Apps } from '@tapis/tapis-typescript' // --------------------------------------------------------------------------- // Shared field selections for deep nested queries @@ -592,7 +593,86 @@ async function user_login_post(_req: any, reply: any) { } // --------------------------------------------------------------------------- -// Export all 13 handlers keyed by operationId +// Tapis proxy endpoints (migrated from model-catalog-fastapi) +// Uses @tapis/tapis-typescript SDK. Forwards the user's Bearer JWT as the +// Tapis X-Tapis-Token via Configuration.apiKey. +// --------------------------------------------------------------------------- +function extractBearerToken(authHeader: string | undefined): string | null { + if (!authHeader) return null + const m = authHeader.match(/^Bearer\s+(.+)$/i) + return m ? m[1] : authHeader +} + +function buildAppsApi(tenant: string, token: string): Apps.ApplicationsApi { + // SDK paths already include `/v3/...`, so basePath must not duplicate it. + const config = new Apps.Configuration({ + basePath: `https://${tenant}.tapis.io`, + apiKey: () => token, + }) + return new Apps.ApplicationsApi(config) +} + +async function custom_tapis_apps_get(req: any, reply: any) { + const tenant = req.params?.tenant + if (!tenant) { + reply.code(400).send({ error: 'tenant path parameter required' }) + return + } + const token = extractBearerToken(req.headers?.authorization) + if (!token) { + reply.code(401).send({ error: 'Authorization header required' }) + return + } + try { + const api = buildAppsApi(tenant, token) + const resp = await api.getApps({}) + const result = (resp.result ?? []) as Array<{ id?: string; version?: string }> + const apps = result.map((a) => ({ tenant, id: a.id, version: a.version })) + reply.code(200).send(apps) + } catch (err: any) { + const status = err?.status ?? err?.response?.status + req.log.error({ err, tenant }, 'Tapis apps list failed') + if (status) { + const body = await (err?.response?.text?.() ?? Promise.resolve(err?.message ?? '')) + reply.code(status).send({ error: 'Tapis error', details: body }) + return + } + reply.code(502).send({ error: 'Bad gateway', details: err?.message }) + } +} + +async function custom_tapis_apps_id_get(req: any, reply: any) { + const tenant = req.params?.tenant + const appId = req.params?.app_id + const appVersion = req.params?.app_version + if (!tenant || !appId || !appVersion) { + reply.code(400).send({ error: 'tenant, app_id, app_version path parameters required' }) + return + } + const token = extractBearerToken(req.headers?.authorization) + if (!token) { + reply.code(401).send({ error: 'Authorization header required' }) + return + } + try { + const api = buildAppsApi(tenant, token) + const resp = await api.getApp({ appId, appVersion }) + const app = (resp.result ?? {}) as Record + reply.code(200).send({ tenant, ...app }) + } catch (err: any) { + const status = err?.status ?? err?.response?.status + req.log.error({ err, tenant, appId, appVersion }, 'Tapis app get failed') + if (status) { + const body = await (err?.response?.text?.() ?? Promise.resolve(err?.message ?? '')) + reply.code(status).send({ error: 'Tapis error', details: body }) + return + } + reply.code(502).send({ error: 'Bad gateway', details: err?.message }) + } +} + +// --------------------------------------------------------------------------- +// Export all handlers keyed by operationId // --------------------------------------------------------------------------- export const customHandlers: Record Promise> = { custom_model_index_get, @@ -607,5 +687,7 @@ export const customHandlers: Record Promise