Skip to content

HEAD history - since - query #96

Closed

Description

@thehabes

There was a sprint focused on cache where implementations of HEAD requests were required. Artifacts remain in the API controller for /since, /history, and /query HEAD requests.

I believe it has been determined that these are not valid HEAD requests. HEAD requests are only for single pieces of data behind a PURL. If we aren't going to use them, we should delete them.

Activity

  1. thehabes commented on Sep 8, 2026

    @thehabes
    MemberAuthor

    Picking this up while doing the paging work in #310, since #304 is blocked on the answer here.

    The scope has shrunk since 2022. Of the three endpoints named above, only /query still has a HEAD artifact:

    Route Declaration HEAD artifact
    /v1/api/query routes/query.js:8-9 — .post() and .head() queryHeadRequest, controllers/history.js:86
    /v1/since/:_id routes/since.js:7 — .get() only none
    /v1/history/:_id routes/history.js:7 — .get() only none

    /since and /history need no decision. They are plain GET routes, Express serves HEAD from the GET handler natively, and the contract test already encodes exactly that at __tests__/core_provider_contract.test.js:80 (// Express serves HEAD from the GET handler natively.). Verified live against the dev deployment — GET and HEAD both return 200 for /v1/id/{id}, /v1/history/{id} and /v1/since/{id}.

    So the question is only about HEAD /v1/api/query, and I think the answer is the one suggested above: remove it.

    Why it is not a valid HEAD request

    RFC 9110 §9.3.2 defines HEAD strictly in terms of GET:

    The HEAD method is identical to GET except that the server MUST NOT send content in the response.

    There is no "HEAD of a POST" in HTTP. /query is a POST endpoint because the filter travels in the request body, so HEAD /v1/api/query is answering a question about a GET /v1/api/query that does not exist.

    And it cannot be rescued by keeping the body, because that is the second half of the problem. RFC 9110 §9.3.1 says a GET request body "has no generally defined semantics, cannot alter the meaning or target of the request, and might lead some implementations to reject the request." HEAD inherits that. The endpoint needs a body to mean anything, and a body is exactly what the method has no defined semantics for.

    This is not theoretical

    Our own test tooling already refuses it. From routes/__tests__/query.test.js:76:

    supertest cannot send a JSON body on HEAD cleanly (superagent rejects the object), so the request body is omitted here.

    The test was written around the refusal rather than the refusal being treated as the signal it is.

    And when the body is dropped, the handler queries for everything. queryHeadRequest has no empty-body guard, where POST /query has an explicit one (controllers/crud.js:78-85, "Hey now, don't ask for everything...this can happen by accident. Don't allow it."). Verified read-only against localhost:3001:

    POST /v1/api/query?limit=2   {}              -> 400   guard fires
    HEAD /v1/api/query?limit=2   {}              -> 200   no guard
    HEAD /v1/api/query?limit=100 (no body)       -> 200   Content-Length: 111788
    

    db.find(undefined) matches everything, so a bodiless HEAD returns a full unfiltered page. Any client, proxy or intermediary that strips the body — which the spec permits — gets that. It is not an access-control problem, since /query is public either way, but it does mean the one verb that cannot reliably carry a filter is also the one with no protection against having none.

    There is a smaller signal pointing the same direction: queryHeadRequest lives in controllers/history.js, not controllers/crud.js alongside query. That is where the 2022 sprint left it.

    What removal touches

    • routes/query.js:9 — drop .head(controller.queryHeadRequest)
    • controllers/history.js:86-107 and its export at :109
    • db-controller.js:16, 38 — the import and re-export
    • openapi/contracts/core-provider.openapi.yaml — drop head: under /api/query. The other four head: declarations (/id/{id}, /id/{id}/expanded, /since/{id}, /history/{id}) are all GET-backed and stay. __tests__/core_provider_contract.test.js cross-checks the spec against routes/api-routes.js, so both move together or the test fails.
    • routes/__tests__/query.test.js:67-100 — the HEAD /query block

    Breaking for anyone calling it, though a client relying on a bodiless HEAD is currently receiving an unfiltered page, so it is worth asking what they think they are getting.

    Effect on #304

    #304 proposes changing HEAD /query from 404 to 200 with Content-Length: 2 on an empty page, and correctly says to answer this issue first. If the handler is removed, that work is moot and #304 should close with it.

    Worth noting that #310 has already fixed the other half of #304 for free, since HEAD /query shares getPagination():

                           before          after #310
    ?limit=abc         200 / 200   ->   400 / 400     POST and HEAD agree
    ?skip=150000       200 / 200   ->   400 / 400     agree
    ?limit=1&limit=2   200 / 200   ->   400 / 400     agree
    empty page         200 / 404   ->   200 / 404     still disagrees
    

    The empty-page status is the only divergence left, and it disappears along with the handler.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    questionFurther information is requested

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions