Skip to content
This repository was archived by the owner on Sep 1, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
# Changelog

## Unreleased

### New features

- `GET /standardvariables` accepts `label_contains=<term>`: 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
Expand Down
29 changes: 26 additions & 3 deletions openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3868,14 +3868,24 @@ paths:
schema:
type: string
style: form
- description: Filter by label
- description: Filter by label (exact match)
explode: true
in: query
name: label
required: false
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
Expand All @@ -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:
Expand All @@ -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
Expand Down
140 changes: 140 additions & 0 deletions src/__tests__/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [] } })
})
})

// ---------------------------------------------------------------------------
Expand Down
116 changes: 116 additions & 0 deletions src/__tests__/standardvariables-ckan-route.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import('@apollo/client/core')>(
'@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<FastifyInstance> {
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')
})
})
Loading
Loading