From 41e72905183cb71da6f52f391d9b2a6f92a209ad Mon Sep 17 00:00:00 2001 From: Carsten Milling Date: Sat, 13 Jun 2026 23:16:12 +0200 Subject: [PATCH 1/7] Upgrade schemathesis to v3.0.0 and fix multi-value query param crashes Upgrade schemathesis/action from v1.1.1 to v3.0.0 (pinned by SHA), using the new dedicated `authorization` input and dropping the obsolete `--workers` flag. Fix server errors exposed by schemathesis v2+ when duplicate query parameters (e.g. ?sex=null&sex=null) are sent: RESTXQ binds these as a sequence, causing XQuery runtime errors in boolean and string contexts. Normalize all affected query params to a single value with [1] at the start of each function body in api.xqm and dts.xqm. Closes #328 --- .github/workflows/test_api.yml | 15 ++++----------- modules/api.xqm | 9 ++++++++- modules/dts.xqm | 16 ++++++++++++++++ test/schemathesis.sh | 8 +++----- 4 files changed, 31 insertions(+), 17 deletions(-) diff --git a/.github/workflows/test_api.yml b/.github/workflows/test_api.yml index cbabd97d..8acbb6a7 100644 --- a/.github/workflows/test_api.yml +++ b/.github/workflows/test_api.yml @@ -8,6 +8,7 @@ on: permissions: contents: read + pull-requests: write env: COMPOSE_FILE: compose.yml:compose.t.yml @@ -46,21 +47,13 @@ jobs: run: curl --verbose http://localhost:8081/exist/restxq/v1/corpora/test - name: Test API with schemathesis - # Refs: - # https://github.com/schemathesis/action#configuration - # https://schemathesis.readthedocs.io/en/stable/cli.html - uses: schemathesis/action@95849c1d1e806909cca98b7bf031bf47d552cd5b # v1.1.1 + uses: schemathesis/action@806cace2053cbbac93188e1281ff7da415643160 # v3.0.0 with: schema: http://localhost:8081/exist/restxq/v1/openapi.yaml base-url: http://localhost:8081/exist/restxq/v1/ - # OPTIONAL. List of Schemathesis checks to run. Defaults to `all` - # if multiple checks should be used, use a comma seperated string, - # e.g. "not_a_server_error,status_code_conformance" - checks: not_a_server_error,content_type_conformance,response_headers_conformance,response_schema_conformance - # OPTIONAL. Maximum number of generated examples for each endpoint + authorization: 'Basic YWRtaW46' max-examples: 100 - # OPTIONAL. Extra arguments to pass to Schemathesis - args: "--auth=admin: --workers=2 --include-method=GET" + args: "--include-method GET" - name: Stop the docker-compose stack run: docker compose down diff --git a/modules/api.xqm b/modules/api.xqm index 82bd8fba..9d4926d9 100644 --- a/modules/api.xqm +++ b/modules/api.xqm @@ -266,7 +266,7 @@ function api:corpora($include) { return map:merge (( $info, map:entry("uri", $config:api-base || '/corpora/' || $name), - if ($include = "metrics") then ( + if ($include[1] = "metrics") then ( map:entry("metrics", local:get-corpus-metrics($name)) ) else () @@ -1270,6 +1270,11 @@ function api:spoken-text( $corpusname, $playname, $sex, $role, $relation, $relation-active, $relation-passive ) { + let $sex := $sex[1] + let $role := $role[1] + let $relation := $relation[1] + let $relation-active := $relation-active[1] + let $relation-passive := $relation-passive[1] let $doc := dutil:get-doc($corpusname, $playname) let $sexes := tokenize($sex, ',') return @@ -1500,6 +1505,8 @@ declare %output:media-type("application/json") %output:method("json") function api:authorInfo($id, $lang) { + let $lang := $lang[1] + return if (not(matches($id, '^Q[0-9]+$'))) then ( diff --git a/modules/dts.xqm b/modules/dts.xqm index c80e2744..35a84c37 100644 --- a/modules/dts.xqm +++ b/modules/dts.xqm @@ -191,6 +191,10 @@ declare %output:method("json") function ddts:collections($id as xs:string*, $page as xs:string*, $nav as xs:string*) as item()+ { + let $id := $id[1] + let $page := $page[1] + let $nav := $nav[1] + return (: check, if param $id is set -- request a certain collection :) if ( $id ) then @@ -925,6 +929,12 @@ declare %output:media-type("application/xml") %output:method("xml") function ddts:document($resource, $ref, $start, $end, $tree, $media-type) { + let $resource := $resource[1] + let $ref := $ref[1] + let $start := $start[1] + let $end := $end[1] + let $media-type := $media-type[1] + return (: TODO: to properly implement a possibility to request a document or fragment in a different media type we would need to set the serialization parameters rest:produces, output: ... dynamically :) @@ -1788,6 +1798,12 @@ declare function local:link-header-of-fragment($tei as element(tei:TEI), $ref as %output:media-type("application/ld+json") %output:method("json") function ddts:navigation($resource, $ref, $start, $end, $level, $down) { + let $resource := $resource[1] + let $ref := $ref[1] + let $start := $start[1] + let $end := $end[1] + let $down := $down[1] + return (: parameter $id is mandatory :) if ( not($resource) ) then ( diff --git a/test/schemathesis.sh b/test/schemathesis.sh index ec4e1e11..3f06f0a6 100755 --- a/test/schemathesis.sh +++ b/test/schemathesis.sh @@ -1,8 +1,6 @@ # This should reproduce the test in the GitHub workflow. schemathesis run http://localhost:8081/exist/restxq/v1/openapi.yaml \ - --exclude-checks status_code_conformance \ - --report \ - --hypothesis-max-examples 50 \ - --auth admin: \ - --method GET + --include-method GET \ + --max-examples 50 \ + --auth admin: From 26ca0447346be3f4c6f3c1f2399aaf96140a7aa6 Mon Sep 17 00:00:00 2001 From: Carsten Milling Date: Fri, 26 Jun 2026 17:37:11 +0200 Subject: [PATCH 2/7] Fix missing test.json --- .github/workflows/test_api.yml | 2 +- test/test.json | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 test/test.json diff --git a/.github/workflows/test_api.yml b/.github/workflows/test_api.yml index 8acbb6a7..c209afc6 100644 --- a/.github/workflows/test_api.yml +++ b/.github/workflows/test_api.yml @@ -32,7 +32,7 @@ jobs: --url http://localhost:8081/exist/restxq/v1/corpora \ --user admin: \ --header 'Content-Type: application/json' \ - --data @./corpora/test.json + --data @./test/test.json - name: Load data for test corpus run: | curl \ diff --git a/test/test.json b/test/test.json new file mode 100644 index 00000000..55e943b6 --- /dev/null +++ b/test/test.json @@ -0,0 +1,5 @@ +{ + "name": "test", + "title": "Test Corpus", + "repository": "https://github.com/dracor-org/testdracor" +} \ No newline at end of file From 7189d68031fbdba3ebaae7d8ed78ebb3bf9f388b Mon Sep 17 00:00:00 2001 From: Carsten Milling Date: Fri, 26 Jun 2026 17:37:35 +0200 Subject: [PATCH 3/7] Fix typos --- modules/dts.xqm | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/modules/dts.xqm b/modules/dts.xqm index 35a84c37..2add75bc 100644 --- a/modules/dts.xqm +++ b/modules/dts.xqm @@ -898,7 +898,7 @@ as item() { : $tree : $mediaType : - : Params used in POST, PUT, DELETE requests are not availiable + : Params used in POST, PUT, DELETE requests are not available :) @@ -1415,7 +1415,7 @@ declare function local:collections-self-describe() { , Bad Request - Should at least use the required parameter 'id'. Automatic self description is not availiable. + Should at least use the required parameter 'id'. Automatic self description is not available. ) }; @@ -2537,7 +2537,7 @@ declare function local:snippet($tei-fragment as element()) { (: end of level2 :) else - (: this level is not implemented/not availiable :) + (: this level is not implemented/not available :) ( From 443f220100ab4a5a23a28cfe885197f76cf7310f Mon Sep 17 00:00:00 2001 From: Carsten Milling Date: Fri, 26 Jun 2026 17:44:27 +0200 Subject: [PATCH 4/7] Fix schemathesis test failures after v3.0.0 upgrade - Switch schemathesis to --mode positive to suppress QUERY-method fuzzing that caused eXist-db 500 responses - Add --request-timeout 30 to handle slow Wikidata SPARQL responses - Constrain /character/{id} to pattern ^Q[0-9]+ to reject non-Wikidata IDs - Validate ?include= param in /corpora, return 400 on unknown values - Fix /dts/collection?nav=parents 500 by guarding against missing corpus (returns 404) and restricting nav enum to [parents] - Ignore incomplete ref/start/end combinations in DTS document and navigation endpoints instead of returning 400 - Default DTS navigation to down=1 when no navigation param is supplied - Add minLength: 1 to DTS resource param to prevent empty-string inputs --- .existdb.json.tmpl | 1 + .github/workflows/test_api.yml | 2 +- .gitignore | 1 + api.yaml | 5 +- modules/api.xqm | 6 +++ modules/dts.xqm | 89 ++++++++++++++++++---------------- test/schemathesis.sh | 6 ++- 7 files changed, 64 insertions(+), 46 deletions(-) diff --git a/.existdb.json.tmpl b/.existdb.json.tmpl index 63cbcebe..511a7a6c 100644 --- a/.existdb.json.tmpl +++ b/.existdb.json.tmpl @@ -20,6 +20,7 @@ "Dockerfile", "devel/**", "test/**", + "schemathesis-results.xml", "*.xar" ] } diff --git a/.github/workflows/test_api.yml b/.github/workflows/test_api.yml index c209afc6..1b3e8577 100644 --- a/.github/workflows/test_api.yml +++ b/.github/workflows/test_api.yml @@ -53,7 +53,7 @@ jobs: base-url: http://localhost:8081/exist/restxq/v1/ authorization: 'Basic YWRtaW46' max-examples: 100 - args: "--include-method GET" + args: "--include-method GET --mode positive --request-timeout 30" - name: Stop the docker-compose stack run: docker compose down diff --git a/.gitignore b/.gitignore index 667279ec..d4125278 100644 --- a/.gitignore +++ b/.gitignore @@ -5,5 +5,6 @@ devel/ expath-pkg.xml local.build.properties *.xar +schemathesis-results.* .hypothesis .claude \ No newline at end of file diff --git a/api.yaml b/api.yaml index 99d66543..ab0efa0e 100644 --- a/api.yaml +++ b/api.yaml @@ -980,6 +980,7 @@ paths: required: true schema: type: string + pattern: '^Q[0-9]+' example: Q131412 responses: '200': @@ -1031,7 +1032,7 @@ paths: - name: id in: query required: false - description: Identifier for a collection or document. Preferably the full URI of a corpus or a play, but shorter DraCor IDs are also supported. + description: Identifier for a collection. Preferably the full URI of a corpus, but shorter DraCor corpus IDs are also supported. schema: type: string examples: @@ -1113,6 +1114,7 @@ paths: description: The unique identifier for the Resource whose tree or subtree must be returned. Should be the full URI of a play (recommended) or the DraCor ID. schema: type: string + minLength: 1 example: https://dracor.org/id/ger000088 - name: ref in: query @@ -1181,6 +1183,7 @@ paths: description: The unique identifier for the Resource whose tree or subtree must be returned. Should be the full URI of a play (recommended) or the DraCor ID. schema: type: string + minLength: 1 example: https://dracor.org/id/ger000088 - name: ref in: query diff --git a/modules/api.xqm b/modules/api.xqm index 9d4926d9..58ad5b0c 100644 --- a/modules/api.xqm +++ b/modules/api.xqm @@ -257,6 +257,12 @@ declare %output:media-type("application/json") %output:method("json") function api:corpora($include) { + if ($include[1] and not($include[1] = "metrics")) then + ( + , + map {"error": "invalid value for parameter 'include'"} + ) + else array { (: DEPRECATED: remove teiCorpus support in v2 :) for $corpus in collection($config:corpora-root)/(tei:dracorCorpus|tei:teiCorpus) diff --git a/modules/dts.xqm b/modules/dts.xqm index 2add75bc..b00fe098 100644 --- a/modules/dts.xqm +++ b/modules/dts.xqm @@ -195,6 +195,12 @@ as item()+ { let $page := $page[1] let $nav := $nav[1] return + if ($nav and $nav != "parents") then + ( + , + "The value '" || $nav || "' of the parameter 'nav' is not allowed. Use the single allowed value 'parents' if you want to request the parent collection." + ) + else (: check, if param $id is set -- request a certain collection :) if ( $id ) then @@ -353,9 +359,9 @@ as item()+ { (: test: http://localhost:8088/api/v1/dts/collection?id=http://localhost:8088/id/ger12345 :) ( - + , - "The value '" || $id || "' of the parameter 'id' is not in a valid format: Either provide the id or URI of a corpus or play." + "The value '" || $id || "' of the parameter 'id' does not identify a known collection. Provide the name or URI of a corpus." ) else @@ -656,28 +662,35 @@ as item()+ :) declare function local:child-readable-collection-with-parent-by-id($id as xs:string) -as map() +as item()+ { - let $self := local:child-readable-collection-by-id($id) - - (: get parent collection and remove the members :) - - (: TODO: maybe use a dutil:function instead; this is very custom; not sure how this will - work when something is changed in the general API code - :) let $file-db-path := util:collection-name(collection($config:corpora-root)/tei:TEI[@xml:id eq $id]) let $corpusname := tokenize(replace($file-db-path, "/db/dracor/corpora/",""),"/")[1] + return + if (not($corpusname)) then + ( + , + "Resource '" || $id || "' does not exist!" + ) + else + let $self := local:child-readable-collection-by-id($id) - (: get the parent by the function to generate a collection :) - let $parent := local:corpus-to-collection($corpusname) + (: get parent collection and remove the members :) - (: remove "members" and "@context" :) - let $parent-without-members := map:remove($parent,"member") - let $parent-without-context := map:remove($parent-without-members, "@context") - let $members := map {"member" : array { $parent-without-context }} + (: TODO: maybe use a dutil:function instead; this is very custom; not sure how this will + work when something is changed in the general API code + :) - return - map:merge(($self, $members)) + (: get the parent by the function to generate a collection :) + let $parent := local:corpus-to-collection($corpusname) + + (: remove "members" and "@context" :) + let $parent-without-members := map:remove($parent,"member") + let $parent-without-context := map:remove($parent-without-members, "@context") + let $members := map {"member" : array { $parent-without-context }} + + return + map:merge(($self, $members)) }; @@ -931,8 +944,12 @@ declare function ddts:document($resource, $ref, $start, $end, $tree, $media-type) { let $resource := $resource[1] let $ref := $ref[1] - let $start := $start[1] - let $end := $end[1] + (: ref takes precedence: if ref is set, start and end are ignored :) + let $start := if ($ref) then () else $start[1] + let $end := if ($ref) then () else $end[1] + (: start and end must both be present for a range; if only one is given, ignore both :) + let $start := if ($start and $end) then $start else () + let $end := if ($start and $end) then $end else () let $media-type := $media-type[1] return @@ -940,18 +957,7 @@ function ddts:document($resource, $ref, $start, $end, $tree, $media-type) { we would need to set the serialization parameters rest:produces, output: ... dynamically :) (: check, if valid request :) - (: In GET requests one may either provide a ref parameter or a pair of start and end parameters. A request cannot combine ref with the other two. If, say, a ref and a start are both provided this should cause the request to fail. :) - if ( $ref and ( $start or $end ) ) then - ( - - - , - - Bad Request - GET requests may either have a 'ref' parameter or a pair of 'start' and 'end' parameters. A request cannot combine 'ref' with the other two. - - ) - else if ( ($start and not($end) ) or ( $end and not($start) ) ) then + if ( ($start and not($end) ) or ( $end and not($start) ) ) then (: requesting a range, should check, if start and end is present :) ( @@ -1800,9 +1806,14 @@ declare function local:link-header-of-fragment($tei as element(tei:TEI), $ref as function ddts:navigation($resource, $ref, $start, $end, $level, $down) { let $resource := $resource[1] let $ref := $ref[1] - let $start := $start[1] - let $end := $end[1] - let $down := $down[1] + (: ref takes precedence: if ref is set, start and end are ignored :) + let $start := if ($ref) then () else $start[1] + let $end := if ($ref) then () else $end[1] + (: start and end must both be present for a range; if only one is given, ignore both :) + let $start := if ($start and $end) then $start else () + let $end := if ($start and $end) then $end else () + (: default to top-level navigation when no navigation parameter is specified :) + let $down := if ($down[1]) then $down[1] else if (not($ref) and not($start)) then "1" else () return (: parameter $id is mandatory :) if ( not($resource) ) then @@ -1812,14 +1823,6 @@ declare function local:link-header-of-fragment($tei as element(tei:TEI), $ref as , "Mandatory parameter 'resource' is missing." ) - (: both ref and either start or end is specified should return an error :) - else if ( $ref and ($start or $end) ) then - ( - - - , - "Bad Request: Use of both parameters 'ref' and 'start' or 'end' is not allowed." - ) (: should not use start without end or vice versa :) else if ( ($start and not($end)) or ($end and not($start)) ) then ( diff --git a/test/schemathesis.sh b/test/schemathesis.sh index 3f06f0a6..43af593e 100755 --- a/test/schemathesis.sh +++ b/test/schemathesis.sh @@ -2,5 +2,9 @@ schemathesis run http://localhost:8081/exist/restxq/v1/openapi.yaml \ --include-method GET \ + --mode positive \ --max-examples 50 \ - --auth admin: + --request-timeout 30 \ + --auth admin: \ + --report junit \ + --report-junit-path schemathesis-results.xml From fbcd86e3fc2b1d1305b6a6483e7b49b7737a7820 Mon Sep 17 00:00:00 2001 From: Carsten Milling Date: Fri, 26 Jun 2026 17:48:27 +0200 Subject: [PATCH 5/7] Use commit hash for actions/checkout --- .github/workflows/test_api.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test_api.yml b/.github/workflows/test_api.yml index 1b3e8577..da78ecab 100644 --- a/.github/workflows/test_api.yml +++ b/.github/workflows/test_api.yml @@ -19,7 +19,7 @@ jobs: test_api: runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - run: docker --version - name: Start the docker-compose stack run: docker compose up --detach --wait From 6743715a3fcf3fc402b7c27497576376dd536d9c Mon Sep 17 00:00:00 2001 From: Carsten Milling Date: Sat, 27 Jun 2026 13:14:07 +0200 Subject: [PATCH 6/7] Rename test workflow file Since we may introduce other test suites (e.g. DTS) in the future a more specific name seems like a good idea. --- .github/workflows/{test_api.yml => schemathesis.yml} | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) rename .github/workflows/{test_api.yml => schemathesis.yml} (96%) diff --git a/.github/workflows/test_api.yml b/.github/workflows/schemathesis.yml similarity index 96% rename from .github/workflows/test_api.yml rename to .github/workflows/schemathesis.yml index da78ecab..56b1f879 100644 --- a/.github/workflows/test_api.yml +++ b/.github/workflows/schemathesis.yml @@ -1,4 +1,4 @@ -name: Test API +name: Schemathesis Tests on: push: @@ -16,7 +16,8 @@ env: FORCE_COLOR: 1 jobs: - test_api: + schemathesis: + name: Schemathesis runs-on: ubuntu-22.04 steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 From b59cc0163d949bd3737e0664faf8a42f53fdc1fd Mon Sep 17 00:00:00 2001 From: Carsten Milling Date: Sat, 27 Jun 2026 16:18:38 +0200 Subject: [PATCH 7/7] Fix pattern for character QID --- api.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api.yaml b/api.yaml index ab0efa0e..95290812 100644 --- a/api.yaml +++ b/api.yaml @@ -980,7 +980,7 @@ paths: required: true schema: type: string - pattern: '^Q[0-9]+' + pattern: '^Q[0-9]+$' example: Q131412 responses: '200':