From f711a6b49d4ed523f6fd365d1da488669b127735 Mon Sep 17 00:00:00 2001 From: Maximiliano Osorio Date: Thu, 27 Aug 2026 21:13:49 -0400 Subject: [PATCH] feat(standardvariables): add label_contains and enable_ckan for CKAN autocomplete CKAN's dataset form autocompletes MINT standard variables against this API. That widget broke when the catalog moved from the v1.8.0 FastAPI service to this one, because two behaviours it relied on were never ported: - `label` filters with `_eq`, so a partial term matched nothing. The widget only returned a suggestion once the user typed the entire variable name. - `enable_ckan` did not exist, so the response was the plain JSON-LD array. CKAN's `parseCompletions` reads `item.name || item.Name || item.Format`, none of which our rows carry, so every suggestion collapsed to "". Add both without changing existing semantics: - `label_contains=` builds `label: { _ilike: "%term%" }`. `%` and `_` are escaped so they match literally -- standard variable labels are full of underscores (`land_surface_wind__speed`), and an unescaped `_` would act as a single-character wildcard. - `enable_ckan=true` projects the result into `{"ResultSet": {"Result": [{"Name": "..."}]}}`. `label` keeps its exact-match behaviour, so existing callers are unaffected. Both params are declared on /standardvariables in openapi.yaml -- required, not just for docs: fastify-openapi-glue validates the querystring and AJV strips undeclared params before the handler sees them. Tested at the service level and through the real router, the latter to cover the AJV stripping and boolean coercion that a direct service call bypasses. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Vh9z6U8JosPJnGqPNQf3fk --- CHANGELOG.md | 11 ++ openapi.yaml | 29 +++- src/__tests__/integration.test.ts | 140 ++++++++++++++++++ .../standardvariables-ckan-route.test.ts | 116 +++++++++++++++ src/service.ts | 73 ++++++++- 5 files changed, 363 insertions(+), 6 deletions(-) create mode 100644 src/__tests__/standardvariables-ckan-route.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f68ac7..3126ca5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## Unreleased + +### New features + +- `GET /standardvariables` accepts `label_contains=`: a case-insensitive substring filter on the label. `%` and `_` in the term are matched literally, so underscore-heavy labels such as `land_surface_wind__speed` search as typed. Available on every list endpoint; documented on `/standardvariables`. +- `GET /standardvariables` accepts `enable_ckan=true`, returning the CKAN autocomplete shape `{"ResultSet": {"Result": [{"Name": "..."}]}}` instead of the default array. Restores the v1.8.0 behaviour that the CKAN dataset form's standard-variable autocomplete depends on. + +### Notes + +- `label` is unchanged and still an exact match. Use `label_contains` for prefix/substring search. + ## v2.1.0 — 2026-05-09 ### Breaking changes diff --git a/openapi.yaml b/openapi.yaml index 76061d4..02bb844 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -3868,7 +3868,7 @@ paths: schema: type: string style: form - - description: Filter by label + - description: Filter by label (exact match) explode: true in: query name: label @@ -3876,6 +3876,16 @@ paths: schema: type: string style: form + - description: >- + Filter by label substring (case-insensitive). Intended for + autocomplete widgets that send a partial term as the user types. + explode: true + in: query + name: label_contains + required: false + schema: + type: string + style: form - description: Page number explode: true in: query @@ -3898,6 +3908,18 @@ paths: minimum: 1 type: integer style: form + - description: >- + Return the CKAN autocomplete response shape instead of the default + array of StandardVariable objects. The CKAN shape nests a Result + list of objects with a Name property under a ResultSet key. + explode: true + in: query + name: enable_ckan + required: false + schema: + default: false + type: boolean + style: form responses: "200": content: @@ -3906,9 +3928,10 @@ paths: items: $ref: "#/components/schemas/StandardVariable" type: array - description: + description: >- Successful response - returns an array with the instances of - StandardVariable. + StandardVariable. When enable_ckan is true the response is instead + a CKAN autocomplete ResultSet object. summary: List all instances of StandardVariable tags: - StandardVariable diff --git a/src/__tests__/integration.test.ts b/src/__tests__/integration.test.ts index eabc9a3..210e025 100644 --- a/src/__tests__/integration.test.ts +++ b/src/__tests__/integration.test.ts @@ -266,6 +266,146 @@ describe('label filter', () => { // The query string should include the label where clause expect(callArgs.query).toContain('label: { _eq: $label }') }) + + it('leaves label as an exact match (no ilike) so existing callers are unaffected', async () => { + mockQuery.mockResolvedValueOnce({ data: { modelcatalog_software: [] } }) + + const req = makeReq({ query: { label: 'CYCLES' } }) + await (CatalogService as any).softwares_get(req, makeReply()) + + const callArgs = mockQuery.mock.calls[0][0] + expect(callArgs.query).not.toContain('_ilike') + expect(callArgs.variables).not.toHaveProperty('labelContains') + }) +}) + +// --------------------------------------------------------------------------- +// Test 5b: label_contains - substring filter used by autocomplete widgets +// --------------------------------------------------------------------------- +describe('label_contains filter', () => { + beforeEach(() => { mockQuery.mockReset() }) + + it('builds a case-insensitive %term% ilike filter', async () => { + mockQuery.mockResolvedValueOnce({ data: { modelcatalog_standard_variable: [] } }) + + const req = makeReq({ query: { label_contains: 'wind' } }) + await (CatalogService as any).standardvariables_get(req, makeReply()) + + const callArgs = mockQuery.mock.calls[0][0] + expect(callArgs.query).toContain('label: { _ilike: $labelContains }') + expect(callArgs.query).toContain('$labelContains: String!') + expect(callArgs.variables).toMatchObject({ labelContains: '%wind%' }) + }) + + it('escapes underscores so they are literal, not single-char wildcards', async () => { + mockQuery.mockResolvedValueOnce({ data: { modelcatalog_standard_variable: [] } }) + + const req = makeReq({ query: { label_contains: 'land_surface' } }) + await (CatalogService as any).standardvariables_get(req, makeReply()) + + const callArgs = mockQuery.mock.calls[0][0] + expect(callArgs.variables).toMatchObject({ labelContains: '%land\\_surface%' }) + }) + + it('escapes percent signs in the search term', async () => { + mockQuery.mockResolvedValueOnce({ data: { modelcatalog_standard_variable: [] } }) + + const req = makeReq({ query: { label_contains: '50%' } }) + await (CatalogService as any).standardvariables_get(req, makeReply()) + + const callArgs = mockQuery.mock.calls[0][0] + expect(callArgs.variables).toMatchObject({ labelContains: '%50\\%%' }) + }) + + it('can be combined with label without dropping either condition', async () => { + mockQuery.mockResolvedValueOnce({ data: { modelcatalog_standard_variable: [] } }) + + const req = makeReq({ query: { label: 'exact_one', label_contains: 'exact' } }) + await (CatalogService as any).standardvariables_get(req, makeReply()) + + const callArgs = mockQuery.mock.calls[0][0] + expect(callArgs.query).toContain('label: { _eq: $label }') + expect(callArgs.query).toContain('label: { _ilike: $labelContains }') + expect(callArgs.variables).toMatchObject({ + label: 'exact_one', + labelContains: '%exact%', + }) + }) +}) + +// --------------------------------------------------------------------------- +// Test 5c: enable_ckan - CKAN autocomplete response shape +// --------------------------------------------------------------------------- +describe('enable_ckan response shape', () => { + beforeEach(() => { mockQuery.mockReset() }) + + const twoVariables = { + data: { + modelcatalog_standard_variable: [ + { id: 'https://w3id.org/okn/i/mint/A', label: 'land_surface_wind__speed', description: null }, + { id: 'https://w3id.org/okn/i/mint/B', label: 'soil_water__temperature', description: null }, + ], + }, + } + + it('wraps results as ResultSet.Result[].Name when enable_ckan=true', async () => { + mockQuery.mockResolvedValueOnce(twoVariables) + + const req = makeReq({ query: { enable_ckan: 'true', label_contains: 'w' } }) + const reply = makeReply() + await (CatalogService as any).standardvariables_get(req, reply) + + expect(reply._status).toBe(200) + expect(reply._body).toEqual({ + ResultSet: { + Result: [ + { Name: 'land_surface_wind__speed' }, + { Name: 'soil_water__temperature' }, + ], + }, + }) + }) + + it('accepts a real boolean true (Fastify coerces the declared param)', async () => { + mockQuery.mockResolvedValueOnce(twoVariables) + + const req = makeReq({ query: { enable_ckan: true } as any }) + const reply = makeReply() + await (CatalogService as any).standardvariables_get(req, reply) + + expect(reply._body).toHaveProperty('ResultSet.Result') + }) + + it('returns the plain array when enable_ckan is absent', async () => { + mockQuery.mockResolvedValueOnce(twoVariables) + + const req = makeReq({ query: {} }) + const reply = makeReply() + await (CatalogService as any).standardvariables_get(req, reply) + + expect(Array.isArray(reply._body)).toBe(true) + expect((reply._body as any[])[0]).toMatchObject({ label: ['land_surface_wind__speed'] }) + }) + + it('treats enable_ckan=false as off rather than truthy', async () => { + mockQuery.mockResolvedValueOnce(twoVariables) + + const req = makeReq({ query: { enable_ckan: 'false' } }) + const reply = makeReply() + await (CatalogService as any).standardvariables_get(req, reply) + + expect(Array.isArray(reply._body)).toBe(true) + }) + + it('returns an empty Result list rather than an array when there are no matches', async () => { + mockQuery.mockResolvedValueOnce({ data: { modelcatalog_standard_variable: [] } }) + + const req = makeReq({ query: { enable_ckan: 'true', label_contains: 'zzzz' } }) + const reply = makeReply() + await (CatalogService as any).standardvariables_get(req, reply) + + expect(reply._body).toEqual({ ResultSet: { Result: [] } }) + }) }) // --------------------------------------------------------------------------- diff --git a/src/__tests__/standardvariables-ckan-route.test.ts b/src/__tests__/standardvariables-ckan-route.test.ts new file mode 100644 index 0000000..e201407 --- /dev/null +++ b/src/__tests__/standardvariables-ckan-route.test.ts @@ -0,0 +1,116 @@ +/** + * Route-level test for the CKAN autocomplete contract. + * + * The unit tests in integration.test.ts call the service directly, which + * bypasses Fastify. This test goes through the real router so it also proves + * that `label_contains` and `enable_ckan` survive fastify-openapi-glue's + * querystring validation (undeclared params are stripped by AJV) and that + * `enable_ckan` is coerced from its query-string form to a boolean. + */ + +import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest' + +const { mockQuery } = vi.hoisted(() => ({ mockQuery: vi.fn() })) + +vi.mock('../hasura/client.js', async () => { + const actual = await vi.importActual( + '@apollo/client/core', + ) + return { + readClient: { query: mockQuery }, + getWriteClient: vi.fn(), + gql: actual.gql, + } +}) + +import { buildApp } from '../app.js' +import type { FastifyInstance } from 'fastify' + +let app: FastifyInstance + +async function getApp(): Promise { + if (!app) { + app = await buildApp() + await app.ready() + } + return app +} + +afterAll(async () => { + if (app) await app.close() +}) + +const TWO_ROWS = { + data: { + modelcatalog_standard_variable: [ + { id: 'https://w3id.org/okn/i/mint/A', label: 'land_surface_wind__speed' }, + { id: 'https://w3id.org/okn/i/mint/B', label: 'land_surface_air__temperature' }, + ], + }, +} + +describe('GET /v2.0.0/standardvariables (CKAN autocomplete contract)', () => { + beforeEach(() => { mockQuery.mockReset() }) + + it('passes label_contains through routing and builds an ilike filter', async () => { + mockQuery.mockResolvedValueOnce(TWO_ROWS) + const instance = await getApp() + + const res = await instance.inject({ + method: 'GET', + url: '/v2.0.0/standardvariables?label_contains=land_surface', + }) + + expect(res.statusCode).toBe(200) + const callArgs = mockQuery.mock.calls[0][0] + expect(callArgs.variables).toMatchObject({ labelContains: '%land\\_surface%' }) + }) + + it('coerces enable_ckan=true and returns the CKAN ResultSet shape', async () => { + mockQuery.mockResolvedValueOnce(TWO_ROWS) + const instance = await getApp() + + const res = await instance.inject({ + method: 'GET', + url: '/v2.0.0/standardvariables?label_contains=land&enable_ckan=true', + }) + + expect(res.statusCode).toBe(200) + expect(JSON.parse(res.payload)).toEqual({ + ResultSet: { + Result: [ + { Name: 'land_surface_wind__speed' }, + { Name: 'land_surface_air__temperature' }, + ], + }, + }) + }) + + it('still returns the plain array without enable_ckan', async () => { + mockQuery.mockResolvedValueOnce(TWO_ROWS) + const instance = await getApp() + + const res = await instance.inject({ + method: 'GET', + url: '/v2.0.0/standardvariables?label_contains=land', + }) + + const body = JSON.parse(res.payload) + expect(Array.isArray(body)).toBe(true) + expect(body[0]).toMatchObject({ label: ['land_surface_wind__speed'] }) + }) + + it('keeps label an exact match through the route', async () => { + mockQuery.mockResolvedValueOnce({ data: { modelcatalog_standard_variable: [] } }) + const instance = await getApp() + + await instance.inject({ + method: 'GET', + url: '/v2.0.0/standardvariables?label=land_surface_wind__speed', + }) + + const callArgs = mockQuery.mock.calls[0][0] + expect(callArgs.variables).toMatchObject({ label: 'land_surface_wind__speed' }) + expect(callArgs.variables).not.toHaveProperty('labelContains') + }) +}) diff --git a/src/service.ts b/src/service.ts index cd988b2..0606500 100644 --- a/src/service.ts +++ b/src/service.ts @@ -51,6 +51,53 @@ function findBadBodyRelationshipId( } +/** + * Escape Postgres LIKE/ILIKE wildcards in user input so that a literal `%` or + * `_` in a search term is matched as itself. Standard variable labels are full + * of underscores (`land_surface_wind__speed`), so leaving `_` unescaped would + * make every underscore a single-character wildcard. + */ +export function escapeLikeWildcards(value: string): string { + return value.replace(/[\\%_]/g, (ch) => `\\${ch}`) +} + +/** + * Interpret a query-string flag. Fastify coerces a declared boolean param, but + * be defensive: an undeclared or hand-built request can still deliver a raw + * string, and `Boolean("false")` is `true`. + */ +export function isTruthyFlag(value: unknown): boolean { + if (typeof value === 'boolean') return value + if (typeof value === 'string') { + const v = value.trim().toLowerCase() + return v === 'true' || v === '1' || v === 'yes' + } + return false +} + +/** + * Shape a transformed row list into the response CKAN's autocomplete widget + * expects: `{ ResultSet: { Result: [{ Name: "..." }] } }`. + * + * CKAN's client-side `parseCompletions` reads `item.name || item.Name || + * item.Format`; our normal rows expose the label under `label` as an array, so + * without this projection every suggestion collapses to an empty string. + */ +export function toCkanResultSet( + rows: Record[], +): { ResultSet: { Result: { Name: string }[] } } { + const results = rows + .map((row) => { + const label = row['label'] + if (Array.isArray(label)) return label.length > 0 ? String(label[0]) : '' + return label == null ? '' : String(label) + }) + .filter((name) => name !== '') + .map((name) => ({ Name: name })) + + return { ResultSet: { Result: results } } +} + /** * Build where clause for software subtype filtering. * Software subtypes (models, emulators, etc.) share the modelcatalog_software table. @@ -88,23 +135,39 @@ class CatalogServiceImpl { } if (!resourceConfig.hasuraTable) { // No backing table -- return empty list (matches v1.8.0 behavior for empty named graphs) - reply.code(200).send([]) + const emptyCkan = isTruthyFlag((req.query || {}).enable_ckan) + reply.code(200).send(emptyCkan ? toCkanResultSet([]) : []) return } - const { username, label, page = 1, per_page = 25 } = req.query || {} + const { + username, + label, + label_contains, + enable_ckan, + page = 1, + per_page = 25, + } = req.query || {} const limit = parseInt(String(per_page), 10) || 25 const offset = (parseInt(String(page), 10) - 1) * limit || 0 + const ckanOutput = isTruthyFlag(enable_ckan) // Build dynamic where clause const whereConditions: string[] = [] const variables: Record = { limit, offset } + // `label` stays an exact match (unchanged, existing callers depend on it). + // `label_contains` is the substring form autocomplete widgets need. if (label) { whereConditions.push('label: { _eq: $label }') variables['label'] = label } + if (label_contains) { + whereConditions.push('label: { _ilike: $labelContains }') + variables['labelContains'] = `%${escapeLikeWildcards(String(label_contains))}%` + } + // Software subtype filter const typeFilter = getSoftwareTypeFilter(resource) if (typeFilter) { @@ -125,6 +188,7 @@ class CatalogServiceImpl { // Build variable declarations for query signature let varDecls = '$limit: Int!, $offset: Int!' if (label) varDecls += ', $label: String!' + if (label_contains) varDecls += ', $labelContains: String!' if (typeFilter) { varDecls += Array.isArray(typeFilter) ? ', $typeFilter: [String!]!' : ', $typeFilter: String!' } @@ -149,7 +213,10 @@ class CatalogServiceImpl { const data = result.data as Record const dataKey = `modelcatalog_${resourceConfig.hasuraTable.replace('modelcatalog_', '')}` const rows: Record[] = (data[dataKey] as Record[]) || [] - reply.code(200).send(transformList(rows, resourceConfig)) + const transformed = transformList(rows, resourceConfig) + reply + .code(200) + .send(ckanOutput ? toCkanResultSet(transformed) : transformed) } catch (err: any) { req.log.error({ err }, 'GraphQL list query failed') reply.code(500).send({ error: 'Internal server error', details: err?.message })