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 })