Skip to content

ID-278: support GET and typed inputs on the client suite $status operation - #51

Merged
ljtucker merged 5 commits into
mainfrom
id-278-status-operation-get-and-param-types
Oct 20, 2026
Merged

ljtucker merged 5 commits into
mainfrom
id-278-status-operation-get-and-param-types

Conversation

@ljtucker

@ljtucker ljtucker commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Previously, the client suite's simulated $status operation did not follow the OperationDefinition when invoked on the type (Subscription/$status):

  • GET was not supported (only the instance-level Subscription/[id]/$status had a GET route)
  • POST read the id and status inputs from valueString instead of valueId and valueCode, so a conformant client got a 404 for its own Subscription

Now, type-level $status supports GET, with inputs as query parameters, and POST, with inputs in a Parameters body. SubscriptionStatusEndpoint handles both invocations, choosing by the request path. Type-level requests use search semantics: when no Subscription matches the inputs, Inferno returns a 200 empty searchset Bundle instead of a 404. Instance-level behavior is unchanged and still ignores the inputs, as the IG specifies. Malformed type-level inputs are rejected with a 400 invalid OperationOutcome that names the problem, including

  1. a POST input whose value isn't a non-empty valueId or valueCode (e.g., valueString, "valueId": 123, or a second value[x]), e.g., "The id parameter must have a non-empty valueId and no other value."
  2. a GET query parameter without a value (e.g., ?id=), e.g., "The id query parameter must have a value."
  3. a POST body that isn't a Parameters resource, which previously caused a 500

NOTE: clients that sent valueString inputs to Subscription/$status previously got a 200 and will now get a 400, and type-level requests that match nothing now get a 200 empty Bundle instead of a 404.

This PR also pins the CI workflows to ubuntu-24.04, since ubuntu-latest no longer supports Ruby 3.3.6.

Testing Guidance

Beyond unit tests (bundle exec rspec spec/subscriptions_test_kit/endpoints), these changes are easiest to see by calling $status from Postman against the client suite once the server suite has created a Subscription.

Setup

  1. Check out this branch
  2. Run the inferno services and the test kit locally (bundle exec inferno services start, then bundle exec inferno start)

Create a Subscription

  1. Create a Subscriptions R4 Client session, apply the "Inferno Subscription R4 Client Empty Preset" preset, and run group "1 Demonstrate the Rest-Hook Subscription Workflow".
  2. Create a Subscriptions R4 Server session, apply the "Empty Notifications Against The Subscriptions Client Suite" preset, and run group "1 Demonstrate the Subscription Workflow".
  3. Wait for the server's "User Action Required" dialog for test 1.1.02 to appear. Leave both sessions waiting.

Call $status with Postman

  1. Import the collection below into Postman (Import > Raw text). It uses Bearer auth with the collection variable access_token = SAMPLE_TOKEN and sends Content-Type: application/fhir+json on the POSTs.
  2. Set the collection variable host to your Inferno (http://localhost:4567 for inferno start, http://localhost for Docker). subscription_id is filled in by request 1.
  3. Run the collection (Run collection, or send each request in order). Each request has tests for its expected result, and all 18 should pass:
    1. Type-level GET, no inputs: 200 searchset Bundle with status active, and stores subscription_id from it
    2. Type-level GET ?id={{subscription_id}}: 200 searchset Bundle (demonstrates GET support)
    3. Type-level GET ?id=other: 200 empty searchset Bundle
    4. Type-level GET ?status=off: 200 empty searchset Bundle
    5. Type-level GET ?id=: 400 "The id query parameter must have a value."
    6. Type-level POST with {"name": "id", "valueId": "{{subscription_id}}"}: 200 searchset Bundle (demonstrates valueId)
    7. Type-level POST with {"name": "id", "valueString": "{{subscription_id}}"}: 400 "The id parameter must have a non-empty valueId and no other value."
    8. Type-level POST with {"name": "status", "valueString": "active"}: 400 "The status parameter must have a non-empty valueCode and no other value."
    9. Instance GET {{subscription_id}}/$status?status=off: 200 searchset Bundle (inputs are ignored on the instance)
  4. Click the links in the server's 1.1.02 dialog and then the client's 1.1.01 dialog. Server group 1 passes, and client groups 1.1 and 1.2 pass (1.3.04 asks for the usual attestation).

NOTE: you can run the collection against main to compare. Request 1 can't find the Subscription there, so first set subscription_id to the id named in the server's 1.1.02 dialog.

Postman collection (ID-278 Subscription $status)
{
  "info": {
    "name": "ID-278 Subscription $status",
    "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
    "description": "Calls $status on the Subscriptions client suite while it waits in test 1.1.01. Set `host` to your Inferno (http://localhost:4567 for `inferno start`, http://localhost for Docker)."
  },
  "auth": {
    "type": "bearer",
    "bearer": [
      {
        "key": "token",
        "value": "{{access_token}}",
        "type": "string"
      }
    ]
  },
  "variable": [
    {
      "key": "host",
      "value": "http://localhost:4567"
    },
    {
      "key": "access_token",
      "value": "SAMPLE_TOKEN"
    },
    {
      "key": "subscription_id",
      "value": ""
    }
  ],
  "item": [
    {
      "name": "1. Type-level GET, no inputs (sets subscription_id)",
      "request": {
        "method": "GET",
        "header": [],
        "url": "{{host}}/custom/subscriptions_r5_backport_r4_client/fhir/Subscription/$status",
        "description": "Returns the status of the Subscription the server suite created and stores its id for the requests below."
      },
      "event": [
        {
          "listen": "test",
          "script": {
            "type": "text/javascript",
            "exec": [
              "pm.test('200 searchset Bundle', () => {",
              "  pm.response.to.have.status(200);",
              "  pm.expect(pm.response.json().type).to.eql('searchset');",
              "});",
              "pm.test('SubscriptionStatus has status active', () => {",
              "  const params = pm.response.json().entry[0].resource.parameter;",
              "  pm.expect(params.find(p => p.name === 'status').valueCode).to.eql('active');",
              "});",
              "const ref = pm.response.json().entry[0].resource.parameter.find(p => p.name === 'subscription').valueReference.reference;",
              "pm.collectionVariables.set('subscription_id', ref.split('/').pop());"
            ]
          }
        }
      ]
    },
    {
      "name": "2. Type-level GET, matching id",
      "request": {
        "method": "GET",
        "header": [],
        "url": "{{host}}/custom/subscriptions_r5_backport_r4_client/fhir/Subscription/$status?id={{subscription_id}}",
        "description": ""
      },
      "event": [
        {
          "listen": "test",
          "script": {
            "type": "text/javascript",
            "exec": [
              "pm.test('200 searchset Bundle', () => {",
              "  pm.response.to.have.status(200);",
              "  pm.expect(pm.response.json().type).to.eql('searchset');",
              "});",
              "pm.test('SubscriptionStatus has status active', () => {",
              "  const params = pm.response.json().entry[0].resource.parameter;",
              "  pm.expect(params.find(p => p.name === 'status').valueCode).to.eql('active');",
              "});"
            ]
          }
        }
      ]
    },
    {
      "name": "3. Type-level GET, other id",
      "request": {
        "method": "GET",
        "header": [],
        "url": "{{host}}/custom/subscriptions_r5_backport_r4_client/fhir/Subscription/$status?id=other",
        "description": ""
      },
      "event": [
        {
          "listen": "test",
          "script": {
            "type": "text/javascript",
            "exec": [
              "pm.test('200 searchset Bundle', () => {",
              "  pm.response.to.have.status(200);",
              "  pm.expect(pm.response.json().type).to.eql('searchset');",
              "});",
              "pm.test('Bundle has no entries', () => {",
              "  pm.expect(pm.response.json().total).to.eql(0);",
              "  pm.expect(pm.response.json().entry || []).to.be.empty;",
              "});"
            ]
          }
        }
      ]
    },
    {
      "name": "4. Type-level GET, non-matching status",
      "request": {
        "method": "GET",
        "header": [],
        "url": "{{host}}/custom/subscriptions_r5_backport_r4_client/fhir/Subscription/$status?status=off",
        "description": ""
      },
      "event": [
        {
          "listen": "test",
          "script": {
            "type": "text/javascript",
            "exec": [
              "pm.test('200 searchset Bundle', () => {",
              "  pm.response.to.have.status(200);",
              "  pm.expect(pm.response.json().type).to.eql('searchset');",
              "});",
              "pm.test('Bundle has no entries', () => {",
              "  pm.expect(pm.response.json().total).to.eql(0);",
              "  pm.expect(pm.response.json().entry || []).to.be.empty;",
              "});"
            ]
          }
        }
      ]
    },
    {
      "name": "5. Type-level GET, id without a value",
      "request": {
        "method": "GET",
        "header": [],
        "url": "{{host}}/custom/subscriptions_r5_backport_r4_client/fhir/Subscription/$status?id=",
        "description": ""
      },
      "event": [
        {
          "listen": "test",
          "script": {
            "type": "text/javascript",
            "exec": [
              "pm.test('400 invalid', () => {",
              "  pm.response.to.have.status(400);",
              "  pm.expect(pm.response.json().issue[0].code).to.eql('invalid');",
              "});",
              "pm.test('names the problem', () => {",
              "  pm.expect(pm.response.json().issue[0].details.text).to.eql(\"The `id` query parameter must have a value.\");",
              "});"
            ]
          }
        }
      ]
    },
    {
      "name": "6. Type-level POST, valueId",
      "request": {
        "method": "POST",
        "header": [
          {
            "key": "Content-Type",
            "value": "application/fhir+json"
          }
        ],
        "url": "{{host}}/custom/subscriptions_r5_backport_r4_client/fhir/Subscription/$status",
        "description": "",
        "body": {
          "mode": "raw",
          "raw": "{\n  \"resourceType\": \"Parameters\",\n  \"parameter\": [\n    {\n      \"name\": \"id\",\n      \"valueId\": \"{{subscription_id}}\"\n    }\n  ]\n}"
        }
      },
      "event": [
        {
          "listen": "test",
          "script": {
            "type": "text/javascript",
            "exec": [
              "pm.test('200 searchset Bundle', () => {",
              "  pm.response.to.have.status(200);",
              "  pm.expect(pm.response.json().type).to.eql('searchset');",
              "});",
              "pm.test('SubscriptionStatus has status active', () => {",
              "  const params = pm.response.json().entry[0].resource.parameter;",
              "  pm.expect(params.find(p => p.name === 'status').valueCode).to.eql('active');",
              "});"
            ]
          }
        }
      ]
    },
    {
      "name": "7. Type-level POST, id as valueString",
      "request": {
        "method": "POST",
        "header": [
          {
            "key": "Content-Type",
            "value": "application/fhir+json"
          }
        ],
        "url": "{{host}}/custom/subscriptions_r5_backport_r4_client/fhir/Subscription/$status",
        "description": "",
        "body": {
          "mode": "raw",
          "raw": "{\n  \"resourceType\": \"Parameters\",\n  \"parameter\": [\n    {\n      \"name\": \"id\",\n      \"valueString\": \"{{subscription_id}}\"\n    }\n  ]\n}"
        }
      },
      "event": [
        {
          "listen": "test",
          "script": {
            "type": "text/javascript",
            "exec": [
              "pm.test('400 invalid', () => {",
              "  pm.response.to.have.status(400);",
              "  pm.expect(pm.response.json().issue[0].code).to.eql('invalid');",
              "});",
              "pm.test('names the problem', () => {",
              "  pm.expect(pm.response.json().issue[0].details.text).to.eql(\"The `id` parameter must have a non-empty `valueId` and no other value.\");",
              "});"
            ]
          }
        }
      ]
    },
    {
      "name": "8. Type-level POST, status as valueString",
      "request": {
        "method": "POST",
        "header": [
          {
            "key": "Content-Type",
            "value": "application/fhir+json"
          }
        ],
        "url": "{{host}}/custom/subscriptions_r5_backport_r4_client/fhir/Subscription/$status",
        "description": "",
        "body": {
          "mode": "raw",
          "raw": "{\n  \"resourceType\": \"Parameters\",\n  \"parameter\": [\n    {\n      \"name\": \"status\",\n      \"valueString\": \"active\"\n    }\n  ]\n}"
        }
      },
      "event": [
        {
          "listen": "test",
          "script": {
            "type": "text/javascript",
            "exec": [
              "pm.test('400 invalid', () => {",
              "  pm.response.to.have.status(400);",
              "  pm.expect(pm.response.json().issue[0].code).to.eql('invalid');",
              "});",
              "pm.test('names the problem', () => {",
              "  pm.expect(pm.response.json().issue[0].details.text).to.eql(\"The `status` parameter must have a non-empty `valueCode` and no other value.\");",
              "});"
            ]
          }
        }
      ]
    },
    {
      "name": "9. Instance GET ignores status input",
      "request": {
        "method": "GET",
        "header": [],
        "url": "{{host}}/custom/subscriptions_r5_backport_r4_client/fhir/Subscription/{{subscription_id}}/$status?status=off",
        "description": ""
      },
      "event": [
        {
          "listen": "test",
          "script": {
            "type": "text/javascript",
            "exec": [
              "pm.test('200 searchset Bundle', () => {",
              "  pm.response.to.have.status(200);",
              "  pm.expect(pm.response.json().type).to.eql('searchset');",
              "});",
              "pm.test('SubscriptionStatus has status active', () => {",
              "  const params = pm.response.json().entry[0].resource.parameter;",
              "  pm.expect(params.find(p => p.name === 'status').valueCode).to.eql('active');",
              "});"
            ]
          }
        }
      ]
    }
  ]
}

🤖 Generated with Claude Code

ljtucker and others added 3 commits October 19, 2026 16:51
…ation

The simulated server now accepts the Subscription $status operation via
GET at the type level, reads POST inputs from valueId and valueCode, and
rejects inputs sent with any other value type with a 400 OperationOutcome.
The status input filter now applies to instance-level invocations too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The IG says the id and status inputs are ignored when $status is invoked
on a Subscription instance, so only the type-level endpoint reads and
validates them. Specs now check the returned status Bundle and error codes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reject GET query inputs without a value and body inputs whose only value
is not a non-empty valueId/valueCode string, order the repeated-parameter
spec so it guards against request.params, and clarify the OR semantics in
the client suite description.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@karlnaden karlnaden left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a few clean up items

Comment thread lib/subscriptions_test_kit/endpoints/subscription_type_status_endpoint.rb Outdated
Comment thread lib/subscriptions_test_kit/endpoints/subscription_type_status_endpoint.rb Outdated
ljtucker and others added 2 commits October 20, 2026 11:20
Changes to the ubuntu-latest image break CI for Ruby 3.3.6, so run the
workflows on ubuntu-24.04 until the Ruby version is upgraded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…hing matches

A single SubscriptionStatusEndpoint now handles both the instance and type
invocations, choosing by the request path. Type-level requests that match
no Subscription return a 200 empty searchset Bundle instead of a 404, per
the operation's search semantics, and input lookups use
SubscriptionSimulationUtils#find_all_parameters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ljtucker
ljtucker merged commit 72f4838 into main Oct 20, 2026
8 checks passed
@ljtucker
ljtucker deleted the id-278-status-operation-get-and-param-types branch October 20, 2026 19:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants