dirigent-integration 0.23.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. dirigent_integration/__init__.py +29 -0
  2. dirigent_integration/py.typed +0 -0
  3. dirigent_integration/shelves/dhis2-analytics-to-csv-report.yaml +174 -0
  4. dirigent_integration/shelves/dhis2-export-to-s3.yaml +66 -0
  5. dirigent_integration/shelves/dhis2-metadata-snapshot.yaml +143 -0
  6. dirigent_integration/shelves/dhis2-tracker-weekly-window.yaml +162 -0
  7. dirigent_integration/shelves/dhis2-values-per-org-unit-to-parquet.yaml +186 -0
  8. dirigent_integration/shelves/dhis2-values-to-parquet.yaml +64 -0
  9. dirigent_integration/shelves/fhir/README.md +79 -0
  10. dirigent_integration/shelves/fhir/dhis2-to-fhir-observations.yaml +212 -0
  11. dirigent_integration/shelves/fhir/fhir-capture-bundle-to-data-values.yaml +230 -0
  12. dirigent_integration/shelves/fhir/fhir-conceptmap-driven-mapping.yaml +222 -0
  13. dirigent_integration/shelves/fhir/fhir-encounter-to-event.yaml +248 -0
  14. dirigent_integration/shelves/fhir/fhir-measure-report-to-analytics-check.yaml +205 -0
  15. dirigent_integration/shelves/fhir/fhir-nightly-window-sync.yaml +182 -0
  16. dirigent_integration/shelves/fhir/fhir-patient-to-tracked-entity.yaml +225 -0
  17. dirigent_integration/shelves/fhir/fhir-questionnaire-response-to-data-values.yaml +187 -0
  18. dirigent_integration/shelves/fhir/fhir-subscription-webhook-to-dhis2.yaml +218 -0
  19. dirigent_integration/shelves/inbound/README.md +32 -0
  20. dirigent_integration/shelves/inbound/csv-drop-to-data-values.yaml +176 -0
  21. dirigent_integration/shelves/inbound/fan-out-per-facility-import.yaml +202 -0
  22. dirigent_integration/shelves/inbound/fhir-observations-to-data-values.yaml +193 -0
  23. dirigent_integration/shelves/inbound/http-json-to-data-values.yaml +151 -0
  24. dirigent_integration/shelves/inbound/outside-to-dhis2-with-checks.yaml +246 -0
  25. dirigent_integration/shelves/inbound/parquet-lakehouse-to-dhis2.yaml +158 -0
  26. dirigent_integration/shelves/inbound/webhook-payload-to-tracker-event.yaml +176 -0
  27. dirigent_integration/shelves/inbound/weekly-window-pull-and-import.yaml +175 -0
  28. dirigent_integration/shelves/parquet-to-dhis2-import.yaml +195 -0
  29. dirigent_integration-0.23.2.dist-info/METADATA +231 -0
  30. dirigent_integration-0.23.2.dist-info/RECORD +33 -0
  31. dirigent_integration-0.23.2.dist-info/WHEEL +4 -0
  32. dirigent_integration-0.23.2.dist-info/entry_points.txt +3 -0
  33. dirigent_integration-0.23.2.dist-info/licenses/LICENSE +15 -0
@@ -0,0 +1,225 @@
1
+ # A FHIR Patient search turned into a DHIS2 tracked entity, posted to /api/tracker by hand.
2
+ #
3
+ # THE FLOW, END TO END. Search the facade's Patient register by identifier, read one Patient
4
+ # out of the searchset Bundle, build the `/api/tracker` payload DHIS2 wants for a tracked
5
+ # entity and its enrollment, and post it with importMode=VALIDATE.
6
+ #
7
+ # WHY http.request AND NOT dhis2.tracker. `dhis2.tracker` READS: it is idempotent, it takes a
8
+ # `kind` of trackedEntities, enrollments or events, and it has no payload field at all. There
9
+ # is no tracker write block in the pack today, so a pipeline that writes tracker objects posts
10
+ # them itself. That works without a second credential, because `http.request` resolves a
11
+ # connection's base URL, basic credentials, TLS setting and timeout STRUCTURALLY, by field
12
+ # name, whatever kind the connection is -- so the `dhis2` connection this pack contributes
13
+ # drives an ordinary HTTP call. One caveat, and it is the reason this comment exists: only
14
+ # `basic_username`/`basic_password` are read that way. A dhis2 connection authenticating with
15
+ # `api_token` reaches `http.request` UNAUTHENTICATED, so a token-based instance needs an
16
+ # `http` connection carrying the token as a header instead.
17
+ #
18
+ # WHAT ARRIVES. The register projects each DHIS2 tracked entity as a Patient:
19
+ #
20
+ # {"resourceType": "Patient", "id": "coxwIkMqNBB",
21
+ # "meta": {"tag": [{"system": "http://dhis2.org/fhir/id/tracked-entity-type",
22
+ # "code": "nEenWmSyUEp"}]},
23
+ # "identifier": [
24
+ # {"system": "http://dhis2.org/fhir/id/tracked-entity", "value": "coxwIkMqNBB"},
25
+ # {"system": "http://dhis2.org/fhir/tracked-entity-attribute/AuPLng5hLbE",
26
+ # "value": "299554435"}],
27
+ # "extension": [{"url": ".../d2-tracked-entity-attribute-value",
28
+ # "extension": [{"url": "attributeId", "valueString": "w75KJ2mc4zz"},
29
+ # {"url": "value", "valueString": "Eden"}]}]}
30
+ #
31
+ # The split is deliberate and it is the whole mapping: a tracked entity attribute DHIS2
32
+ # declares `unique` is projected as an `identifier` under its own per-attribute system, and
33
+ # every other attribute rides as a `D2TrackedEntityAttributeValue` extension. Both are
34
+ # attributes on the way back in.
35
+ #
36
+ # WHAT LEAVES. One `/api/tracker` document:
37
+ #
38
+ # {"trackedEntities": [
39
+ # {"trackedEntity": "coxwIkMqNBB", "trackedEntityType": "nEenWmSyUEp",
40
+ # "orgUnit": "ImspTQPwCqd",
41
+ # "attributes": [{"attribute": "w75KJ2mc4zz", "value": "Eden"},
42
+ # {"attribute": "AuPLng5hLbE", "value": "299554435"}],
43
+ # "enrollments": [{"enrollment": "EnAaBbCcDd1", "program": "IpHINAT79UW",
44
+ # "orgUnit": "ImspTQPwCqd", "enrolledAt": "2026-09-01",
45
+ # "status": "ACTIVE"}]}]}
46
+ #
47
+ # WHICH SYSTEM BECOMES WHAT.
48
+ #
49
+ # meta.tag under .../id/tracked-entity-type -> trackedEntityType
50
+ # identifier under .../id/tracked-entity -> trackedEntity (the uid)
51
+ # identifier under .../tracked-entity-attribute/<uid> -> one attribute, uid from the system
52
+ # D2TrackedEntityAttributeValue extension -> one attribute, uid from attributeId
53
+ #
54
+ # WHERE A READER CHANGES IT. `program` is the tracker program to enrol into and `org_unit` the
55
+ # unit that owns the registration; both are DHIS2 uids and neither is anywhere in the Patient,
56
+ # because the register publishes people rather than enrollments. `search_identifier` is what
57
+ # picks the person: a bare value tries every key, `system|value` pins one.
58
+ #
59
+ # WHAT THIS DOES NOT DO. It does not mint an enrollment uid. A registration needs one, DHIS2
60
+ # does not generate it for this shape, and inventing an 11-character uid in jq would teach the
61
+ # wrong lesson -- so `enrollment_uid` is a parameter, which is exactly where a real forwarder
62
+ # derives one deterministically so a rehearsal and an import name the same object.
63
+ #
64
+ # dg run --local examples/fhir/fhir-patient-to-tracked-entity.yaml
65
+ # dg run --local examples/fhir/fhir-patient-to-tracked-entity.yaml -p search_identifier=299554435
66
+
67
+ format: dirigent/v1
68
+ kind: pipeline
69
+ code: fhir-patient-to-tracked-entity
70
+ name: FHIR Patient to a DHIS2 tracked entity
71
+ description: |
72
+ Search the facade's `Patient` register and build the `/api/tracker` payload for one tracked
73
+ entity and its enrollment, posted with `importMode=VALIDATE`.
74
+
75
+ `dhis2.tracker` reads and does not write, so the write is an ordinary `http.request` on the
76
+ `dhis2` connection -- which works because the HTTP blocks read `base_url` and the basic
77
+ credentials structurally, by field name, from a connection of any kind.
78
+
79
+ tags: [fhir, dhis2, cross-boundary]
80
+
81
+ requires:
82
+ blocks:
83
+ - http.request
84
+ - transform.jq
85
+
86
+ connections:
87
+ fhir-facade:
88
+ kind: http
89
+ config:
90
+ base_url: http://localhost:8095
91
+ timeout: 30s
92
+ # Driven by two different blocks below: `dhis2.*` blocks would resolve it through
93
+ # dhis2w-client, and `http.request` resolves the same record structurally. The password is a
94
+ # SecretStr either way -- sealed on the way in, redacted in every read back.
95
+ dhis2-demo:
96
+ kind: dhis2
97
+ config:
98
+ base_url: https://play.im.dhis2.org/stable-2-43-1
99
+ basic_username: admin
100
+ basic_password: district
101
+ timeout: 60s
102
+
103
+ params:
104
+ type: object
105
+ properties:
106
+ search_identifier:
107
+ type: string
108
+ description: The identifier token to find the person by; bare value, or system|value.
109
+ default: "299554435"
110
+ tracked_entity_type:
111
+ type: string
112
+ description: Which register to search, over meta.tag.
113
+ default: nEenWmSyUEp
114
+ program:
115
+ type: string
116
+ description: The tracker program to enrol into. Not in the Patient, because the register
117
+ publishes people and not enrollments.
118
+ default: IpHINAT79UW
119
+ org_unit:
120
+ type: string
121
+ description: The org unit owning the registration.
122
+ default: ImspTQPwCqd
123
+ enrollment_uid:
124
+ type: string
125
+ description: The enrollment's own uid, which the client mints. A real forwarder derives
126
+ it from the receipt so a rehearsal and an import name the same object.
127
+ default: EnAaBbCcDd1
128
+ enrolled_at:
129
+ type: string
130
+ description: The enrolment date, a zone-less wall clock in DHIS2's vocabulary.
131
+ default: "2026-09-01"
132
+
133
+ steps:
134
+ # The register answers exactly three search parameters -- identifier, _tag and d2-attribute
135
+ # -- and REFUSES anything else with a 400 naming the three, rather than ignoring it. That is
136
+ # the opposite of the definitional types, which ignore what they do not know: an unapplied
137
+ # filter on a register would otherwise answer with every person in the country.
138
+ find_patient:
139
+ block: http.request
140
+ config:
141
+ connection: fhir-facade
142
+ path: /Patient
143
+ method: GET
144
+ query:
145
+ identifier: "${params.search_identifier}"
146
+ _tag: "${params.tracked_entity_type}"
147
+ _count: 1
148
+ headers:
149
+ Accept: application/fhir+json
150
+
151
+ # Both attribute carriers are read here, and they union into one `attributes` array. The
152
+ # per-attribute identifier system ends in the attribute's own uid, so the uid is the last
153
+ # path segment of the system rather than a field of its own.
154
+ to_tracker_payload:
155
+ block: transform.jq
156
+ depends_on: [find_patient]
157
+ config:
158
+ input:
159
+ bundle: "${steps.find_patient.output.body}"
160
+ program: "${params.program}"
161
+ org_unit: "${params.org_unit}"
162
+ enrollment: "${params.enrollment_uid}"
163
+ enrolled_at: "${params.enrolled_at}"
164
+ program: |
165
+ . as {$bundle, $program, $org_unit, $enrollment, $enrolled_at}
166
+ | ($bundle.entry // [])[0].resource as $p
167
+ | if $p == null then error("the register answered no Patient for that identifier") else . end
168
+ | {trackedEntities: [{
169
+ trackedEntity: (
170
+ [$p.identifier[] | select(.system | endswith("/id/tracked-entity"))][0].value),
171
+ trackedEntityType: (
172
+ [$p.meta.tag[] | select(.system | endswith("/id/tracked-entity-type"))][0].code),
173
+ orgUnit: $org_unit,
174
+ attributes: (
175
+ [$p.identifier[]
176
+ | select(.system | contains("/tracked-entity-attribute/"))
177
+ | {attribute: (.system | split("/") | last), value: .value}]
178
+ + [($p.extension // [])[]
179
+ | select(.url | endswith("/d2-tracked-entity-attribute-value"))
180
+ | {attribute: ([.extension[] | select(.url == "attributeId").valueString][0]),
181
+ value: ([.extension[] | select(.url == "value").valueString][0])}]),
182
+ enrollments: [{
183
+ enrollment: $enrollment,
184
+ program: $program,
185
+ orgUnit: $org_unit,
186
+ enrolledAt: $enrolled_at,
187
+ status: "ACTIVE"}]}]}
188
+
189
+ # A dry run on /api/tracker is importMode=VALIDATE. It is NOT dryRun=true -- that is
190
+ # /api/dataValueSets -- and reaching for the wrong spelling on either endpoint does not
191
+ # fail, it imports.
192
+ #
193
+ # A payload carrying `trackedEntities` implies CREATE_AND_UPDATE. Enrolling a person who
194
+ # already exists is a different document -- a top-level `enrollments` array with no
195
+ # trackedEntities wrapper, under plain CREATE -- because the wrapper would rewrite the
196
+ # person's owning org unit on the way past.
197
+ rehearse_import:
198
+ block: http.request
199
+ depends_on: [to_tracker_payload]
200
+ config:
201
+ connection: dhis2-demo
202
+ path: /api/tracker
203
+ method: POST
204
+ query:
205
+ importMode: VALIDATE
206
+ headers:
207
+ Content-Type: application/json
208
+ body: "${steps.to_tracker_payload.output.value}"
209
+ # /api/tracker answers an async job with 200 and a completed one with 200 or 409; the
210
+ # report is in the body either way, so a 409 is a verdict to read rather than a
211
+ # transport failure to fail the step on.
212
+ success_status: [200, 201, 409]
213
+
214
+ # The tracker import report, not the HTTP status. `status` is the instance's own word, and
215
+ # validationReport.errorReports names every refused object with its DHIS2 error code.
216
+ read_report:
217
+ block: transform.jq
218
+ depends_on: [rehearse_import]
219
+ config:
220
+ input: "${steps.rehearse_import.output.body}"
221
+ program: |
222
+ {status: .status,
223
+ stats: (.stats // .response.stats),
224
+ errors: [((.validationReport // .response.validationReport).errorReports // [])[]
225
+ | {code: .errorCode, uid: .uid, message: .message}]}
@@ -0,0 +1,187 @@
1
+ # One QuestionnaireResponse mapped onto data elements by a linkId lookup the document carries.
2
+ #
3
+ # THE FLOW, END TO END. Read one capture off the facade by its receipt id, translate its
4
+ # answers into data values through an explicit linkId-to-data-element table held in params,
5
+ # and import the result -- dry run first.
6
+ #
7
+ # WHY A LOOKUP AT ALL. On a `d2w fhir serve` facade the linkIds already ARE DHIS2 uids, so
8
+ # capture-bundle-to-data-values.yaml needs no table: it splits the dotted linkId and is done.
9
+ # Every other FHIR server in the world uses linkIds a form designer chose -- "anc.visits.total",
10
+ # "q3b", "weight_kg" -- and then the table IS the integration. This document is the general
11
+ # case, and the facade is only where the example gets a real response to run against.
12
+ #
13
+ # WHAT ARRIVES. One QuestionnaireResponse, answers keyed by whatever the form calls them:
14
+ #
15
+ # {"resourceType": "QuestionnaireResponse", "status": "completed",
16
+ # "subject": {"reference": "Location/dczh6Jfd4no"},
17
+ # "item": [{"linkId": "anc.visits.first", "answer": [{"valueInteger": 42}]},
18
+ # {"linkId": "anc.visits.followup", "answer": [{"valueInteger": 17}]},
19
+ # {"linkId": "anc.notes", "answer": [{"valueString": "clinic closed on the 4th"}]}]}
20
+ #
21
+ # WHAT LEAVES. The same `/api/dataValueSets` envelope every aggregate import wants:
22
+ #
23
+ # {"dataSet": "lyLU2wR22tC", "period": "202608", "orgUnit": "dczh6Jfd4no",
24
+ # "dataValues": [{"dataElement": "GMd99K8gVut", "categoryOptionCombo": "qNCMOhkoQju",
25
+ # "value": "42"}]}
26
+ #
27
+ # WHERE A READER CHANGES IT. `link_ids` below, and nowhere else. Each entry maps one linkId to
28
+ # the DHIS2 cell it means: `data_element` alone for an undisaggregated data element, plus
29
+ # `category_option_combo` for one column of a category combination. A linkId absent from the
30
+ # table is dropped rather than guessed at, and the drop is counted and reported, because an
31
+ # answer that silently vanishes is worse than an import that refuses.
32
+ #
33
+ # dg run --local examples/fhir/fhir-questionnaire-response-to-data-values.yaml
34
+ # dg run --local examples/fhir/fhir-questionnaire-response-to-data-values.yaml -p period=202607
35
+
36
+ format: dirigent/v1
37
+ kind: pipeline
38
+ code: fhir-questionnaire-response-to-data-values
39
+ name: QuestionnaireResponse through a linkId table
40
+ description: |
41
+ Map one `QuestionnaireResponse` onto DHIS2 data values through an explicit linkId lookup,
42
+ for the general case where a form's `linkId` is a designer's name rather than a DHIS2 uid.
43
+
44
+ A linkId the table does not name is dropped and counted, never guessed at.
45
+
46
+ tags: [fhir, dhis2, cross-boundary]
47
+
48
+ requires:
49
+ blocks:
50
+ - value.const
51
+ - transform.jq
52
+ - dhis2.data_value_set_import
53
+
54
+ connections:
55
+ dhis2-demo:
56
+ kind: dhis2
57
+ config:
58
+ base_url: https://play.im.dhis2.org/stable-2-43-1
59
+ basic_username: admin
60
+ basic_password: district
61
+ timeout: 60s
62
+
63
+ params:
64
+ type: object
65
+ properties:
66
+ data_set:
67
+ type: string
68
+ description: The DHIS2 data set the answers belong to.
69
+ default: lyLU2wR22tC
70
+ org_unit:
71
+ type: string
72
+ description: The org unit reporting, which a real capture reads off subject.reference.
73
+ default: dczh6Jfd4no
74
+ period:
75
+ type: string
76
+ description: The DHIS2 ISO period, which is a period identifier and not a date range.
77
+ default: "202608"
78
+ link_ids:
79
+ type: object
80
+ description: |
81
+ The mapping. One entry per linkId the form can answer: data_element on its own is an
82
+ undisaggregated data element, and category_option_combo names one column of a
83
+ category combination.
84
+ default:
85
+ anc.visits.first:
86
+ data_element: GMd99K8gVut
87
+ category_option_combo: qNCMOhkoQju
88
+ anc.visits.followup:
89
+ data_element: GMd99K8gVut
90
+ category_option_combo: LbeIlyHEhKr
91
+ anc.staff.count:
92
+ data_element: wfKKFhBn0Q0
93
+
94
+ steps:
95
+ # The capture, written inline so the document runs with nothing set up. In service it is the
96
+ # body of a webhook delivery (fhir-subscription-webhook-to-dhis2.yaml) or one entry of a
97
+ # searchset Bundle (capture-bundle-to-data-values.yaml); either way this step's output is
98
+ # exactly one QuestionnaireResponse, and nothing downstream knows where it came from.
99
+ receive:
100
+ block: value.const
101
+ config:
102
+ value:
103
+ resourceType: QuestionnaireResponse
104
+ status: completed
105
+ authored: "2026-08-31T09:00:00Z"
106
+ questionnaire: http://example.org/fhir/Questionnaire/anc-monthly
107
+ subject:
108
+ reference: Location/dczh6Jfd4no
109
+ item:
110
+ - linkId: anc.visits.first
111
+ text: First antenatal visits
112
+ answer:
113
+ - valueInteger: 42
114
+ - linkId: anc.visits.followup
115
+ text: Follow-up antenatal visits
116
+ answer:
117
+ - valueInteger: 17
118
+ - linkId: anc.staff.count
119
+ text: Midwives on duty
120
+ answer:
121
+ - valueInteger: 3
122
+ # Answered, and mapped to nothing. It must not reach DHIS2, and it must not vanish
123
+ # in silence either: the translation counts it.
124
+ - linkId: anc.notes
125
+ text: Notes
126
+ answer:
127
+ - valueString: clinic closed on the 4th
128
+ # Asked and not answered. An absent answer and a zero are different facts.
129
+ - linkId: anc.visits.referred
130
+ text: Referrals
131
+
132
+ # The translation. It hands on the import document AND the drops beside it, so the receipt
133
+ # step below can report what did not map without re-deriving anything.
134
+ translate:
135
+ block: transform.jq
136
+ depends_on: [receive]
137
+ config:
138
+ input:
139
+ response: "${steps.receive.output.value}"
140
+ link_ids: "${params.link_ids}"
141
+ data_set: "${params.data_set}"
142
+ org_unit: "${params.org_unit}"
143
+ period: "${params.period}"
144
+ program: |
145
+ . as {$response, $link_ids, $data_set, $org_unit, $period}
146
+ | [$response.item[] | recurse(.item[]?) | select((.answer // []) | length > 0)] as $answered
147
+ | {document: {
148
+ dataSet: $data_set,
149
+ period: $period,
150
+ orgUnit: $org_unit,
151
+ dataValues: [
152
+ $answered[]
153
+ | select($link_ids[.linkId] != null)
154
+ | $link_ids[.linkId] as $cell
155
+ | (.answer[0] | (.valueDecimal // .valueInteger // .valueString // .valueBoolean)) as $v
156
+ | {dataElement: $cell.data_element, value: ($v | tostring)}
157
+ + (if $cell.category_option_combo
158
+ then {categoryOptionCombo: $cell.category_option_combo} else {} end)]},
159
+ unmapped: [$answered[] | select($link_ids[.linkId] == null) | .linkId]}
160
+
161
+ rehearse:
162
+ block: dhis2.data_value_set_import
163
+ depends_on: [translate]
164
+ config:
165
+ connection: dhis2-demo
166
+ data_values: "${steps.translate.output.value.document}"
167
+ # dryRun on /api/dataValueSets. The instance validates every value against the data set,
168
+ # the period and the org unit and reports conflicts, and writes nothing.
169
+ dry_run: true
170
+ atomic_mode: ALL
171
+
172
+ # What the run is for: the import summary's counts beside the linkIds nothing claimed. A
173
+ # growing `unmapped` list is the table falling behind the form, which is the failure mode
174
+ # this whole shape exists to make visible.
175
+ receipt:
176
+ block: transform.jq
177
+ depends_on: [rehearse]
178
+ config:
179
+ input:
180
+ summary: "${steps.rehearse.output}"
181
+ unmapped: "${steps.translate.output.value.unmapped}"
182
+ program: |
183
+ {rehearsed: .summary.status,
184
+ would_import: .summary.imported,
185
+ would_update: .summary.updated,
186
+ conflicts: (.summary.conflicts | length),
187
+ unmapped_link_ids: .unmapped}
@@ -0,0 +1,218 @@
1
+ # A FHIR Subscription notification delivered to a webhook, translated, and imported.
2
+ #
3
+ # THE FLOW, END TO END. A FHIR server holds a Subscription that watches for new captures. When
4
+ # one arrives it POSTs a notification to this pipeline's webhook endpoint; the webhook maps the
5
+ # notified resource onto a parameter, the document translates it into a data value set, and the
6
+ # import runs as a dry run.
7
+ #
8
+ # THE SUBSCRIPTION CONTRACT, WHICH IS THE POINT OF THIS FILE. A FHIR Subscription is a standing
9
+ # request: "when a resource matching this search appears, tell me here". Three fields carry it.
10
+ #
11
+ # criteria a search string, exactly as it would be typed against the server, e.g.
12
+ # "QuestionnaireResponse?questionnaire=http://example.org/fhir/Questionnaire/lyLU2wR22tC"
13
+ # channel.type how to be told. `rest-hook` is an HTTP POST, which is what a dirigent
14
+ # webhook is; the others are websocket, email, and message.
15
+ # channel.endpoint where to POST it -- the hooks URL the instance minted for this pipeline.
16
+ #
17
+ # What arrives in that POST depends on `channel.payload`, and the three choices are a real
18
+ # decision rather than a formality:
19
+ #
20
+ # absent a PING. An empty body: "something matched, come and look". The pipeline
21
+ # then has to search the server itself, which is more work and is also the
22
+ # only choice that never puts patient data in a webhook body.
23
+ # id-only the notification names the resource; the pipeline reads it back.
24
+ # full-resource the resource rides in the notification. One request, no read-back, and
25
+ # the delivery is now carrying clinical data -- so it is signed, and the
26
+ # endpoint is not on the public internet.
27
+ #
28
+ # This document takes full-resource, because it is the shape that shows the whole translation
29
+ # in one file. In R4 that body is the bare resource; R5 and the R4 backport wrap it in a
30
+ # history Bundle whose first entry is a SubscriptionStatus and whose second is the resource,
31
+ # which is the shape mapped below and the one most servers now send.
32
+ #
33
+ # `d2w fhir serve` HAS NO SUBSCRIPTION. Its capture surface is `POST /QuestionnaireResponse`
34
+ # and its notification surface is the receipt spool -- `GET /facade/spool`, whose lifecycle
35
+ # moves received -> forwarded or rejected. A pipeline pulls that spool on a schedule instead
36
+ # (fhir-nightly-window-sync.yaml is that shape). This document is for the FHIR server that DOES
37
+ # support Subscription, which is most of them, and it is written so the same translation serves
38
+ # either way.
39
+ #
40
+ # WHAT ARRIVES. The notification body:
41
+ #
42
+ # {"resourceType": "Bundle", "type": "history",
43
+ # "entry": [
44
+ # {"resource": {"resourceType": "SubscriptionStatus", "type": "event-notification",
45
+ # "eventsSinceSubscriptionStart": "17"}},
46
+ # {"resource": {"resourceType": "QuestionnaireResponse", "status": "completed",
47
+ # "questionnaire": ".../Questionnaire/lyLU2wR22tC",
48
+ # "subject": {"reference": "Location/dczh6Jfd4no"},
49
+ # "extension": [{"url": ".../d2-period",
50
+ # "extension": [{"url": "iso", "valueString": "202608"}]}],
51
+ # "item": [...]}}]}
52
+ #
53
+ # WHAT LEAVES. The `/api/dataValueSets` envelope, same as every inbound document here:
54
+ #
55
+ # {"dataSet": "lyLU2wR22tC", "period": "202608", "orgUnit": "dczh6Jfd4no",
56
+ # "dataValues": [{"dataElement": "GMd99K8gVut",
57
+ # "categoryOptionCombo": "qNCMOhkoQju", "value": "426.3"}]}
58
+ #
59
+ # WHERE A READER CHANGES IT. The webhook's `params_from_payload` at the foot of this file, and
60
+ # nothing else. The mapping is STRICT: each parameter names one dotted path into the body, a
61
+ # path the body does not have refuses the delivery, and a parameter the mapping does not
62
+ # mention cannot be reached by any caller whatever they POST. A server sending the bare R4
63
+ # resource rather than a Bundle changes `capture` to `$` -- which this syntax spells as the
64
+ # resource's own top-level keys, so the mapping names them.
65
+ #
66
+ # WHAT LIVES ON THE INSTANCE AND NOT IN GIT. The endpoint's token, whether it is active, and
67
+ # every delivery it has taken. `dg webhook rotate-token` shows a token exactly once.
68
+ #
69
+ # dg run --local examples/fhir/fhir-subscription-webhook-to-dhis2.yaml
70
+ #
71
+ # Against an instance, where the endpoint exists:
72
+ #
73
+ # dg apply examples/fhir/fhir-subscription-webhook-to-dhis2.yaml
74
+ # dg webhook rotate-token fhir-subscription-webhook-to-dhis2 fhir-notification
75
+ # curl -X POST "$DG_URL/hooks/$TOKEN" -H 'Content-Type: application/json' --data @notification.json
76
+
77
+ format: dirigent/v1
78
+ kind: pipeline
79
+ code: fhir-subscription-webhook-to-dhis2
80
+ name: A FHIR Subscription notification imported
81
+ description: |
82
+ Take a FHIR `Subscription` rest-hook notification on a webhook, translate the notified
83
+ `QuestionnaireResponse` into a DHIS2 data value set, and rehearse the import.
84
+
85
+ The subscription contract is three fields: `criteria` (a search string), `channel.type`
86
+ (`rest-hook` is an HTTP POST), and `channel.endpoint` (the hooks URL). `channel.payload`
87
+ decides whether the delivery is a ping, an id, or the resource itself -- this takes the
88
+ resource, wrapped in the history `Bundle` R5 and the R4 backport send.
89
+
90
+ tags: [fhir, dhis2, cross-boundary]
91
+
92
+ requires:
93
+ blocks:
94
+ - transform.jq
95
+ - dhis2.data_value_set_import
96
+
97
+ connections:
98
+ dhis2-demo:
99
+ kind: dhis2
100
+ config:
101
+ base_url: https://play.im.dhis2.org/stable-2-43-1
102
+ basic_username: admin
103
+ basic_password: district
104
+ timeout: 60s
105
+
106
+ params:
107
+ type: object
108
+ properties:
109
+ capture:
110
+ type: object
111
+ description: The notified QuestionnaireResponse. The default is what a delivery would
112
+ have carried, so the document runs without one.
113
+ default:
114
+ resourceType: QuestionnaireResponse
115
+ status: completed
116
+ authored: "2026-08-31T09:00:00Z"
117
+ questionnaire: http://example.org/fhir/Questionnaire/lyLU2wR22tC
118
+ subject:
119
+ reference: Location/dczh6Jfd4no
120
+ extension:
121
+ - url: http://example.org/fhir/StructureDefinition/d2-period
122
+ extension:
123
+ - url: iso
124
+ valueString: "202608"
125
+ - url: type
126
+ valueCode: Monthly
127
+ - url: http://example.org/fhir/StructureDefinition/d2-attribute-option-combo
128
+ valueCoding:
129
+ code: N7QFN41eTN8
130
+ item:
131
+ - linkId: GMd99K8gVut
132
+ item:
133
+ - linkId: GMd99K8gVut.qNCMOhkoQju
134
+ answer:
135
+ - valueDecimal: 426.3
136
+ - linkId: GMd99K8gVut.LbeIlyHEhKr
137
+ answer:
138
+ - valueDecimal: 628.2
139
+ - linkId: wfKKFhBn0Q0
140
+ answer:
141
+ - valueDecimal: 142.1
142
+ events_since_start:
143
+ type: string
144
+ description: The SubscriptionStatus counter. A gap between two deliveries means a
145
+ notification was lost, which is the only way this channel says so.
146
+ default: "0"
147
+
148
+ steps:
149
+ # Only a completed response is translated. An in-progress one is a form still being filled,
150
+ # and a notification is allowed to fire on it -- `criteria` matches a resource, not a state.
151
+ translate:
152
+ block: transform.jq
153
+ config:
154
+ input: "${params.capture}"
155
+ program: |
156
+ if .status != "completed" then
157
+ error("the notified response is \(.status), not completed; nothing to import")
158
+ else . end
159
+ | . as $r
160
+ | {dataSet: ($r.questionnaire | split("/") | last),
161
+ period: ([$r.extension[] | select(.url | endswith("/d2-period")).extension[]
162
+ | select(.url == "iso").valueString][0]),
163
+ orgUnit: ($r.subject.reference | ltrimstr("Location/")),
164
+ attributeOptionCombo: (
165
+ [$r.extension[] | select(.url | endswith("/d2-attribute-option-combo"))][0]
166
+ | .valueCoding.code),
167
+ dataValues: [
168
+ $r.item[] | recurse(.item[]?)
169
+ | select((.answer // []) | length > 0)
170
+ | (.linkId | split(".")) as $key
171
+ | (.answer[0] | (.valueDecimal // .valueInteger // .valueString // .valueBoolean)) as $v
172
+ | {dataElement: $key[0], value: ($v | tostring)}
173
+ + (if ($key | length) > 1 then {categoryOptionCombo: $key[1]} else {} end)]}
174
+ | del(.attributeOptionCombo | select(. == null))
175
+
176
+ # A notification arrives once and is not replayed, so the import's retry budget is what
177
+ # stands between a momentary DHIS2 and a lost capture. dry_run makes this a rehearsal;
178
+ # dropping it is the whole difference between this document and a live forwarder.
179
+ rehearse:
180
+ block: dhis2.data_value_set_import
181
+ depends_on: [translate]
182
+ retry:
183
+ # The budget counts the first try, so this is one attempt and two retries.
184
+ max_attempts: 3
185
+ backoff: 5s
186
+ config:
187
+ connection: dhis2-demo
188
+ data_values: "${steps.translate.output.value}"
189
+ dry_run: true
190
+ atomic_mode: ALL
191
+
192
+ receipt:
193
+ block: transform.jq
194
+ depends_on: [rehearse]
195
+ config:
196
+ input:
197
+ summary: "${steps.rehearse.output}"
198
+ events_since_start: "${params.events_since_start}"
199
+ document: "${steps.translate.output.value}"
200
+ program: |
201
+ {notification: .events_since_start,
202
+ data_set: .document.dataSet,
203
+ period: .document.period,
204
+ org_unit: .document.orgUnit,
205
+ status: .summary.status,
206
+ would_import: .summary.imported,
207
+ conflicts: (.summary.conflicts | length)}
208
+
209
+ triggers:
210
+ webhooks:
211
+ # The whole declaration. `capture` takes the notified resource out of the history Bundle's
212
+ # second entry -- the first is the SubscriptionStatus -- and the counter comes off that
213
+ # status. Both are validated against params above before a run is created, so a delivery
214
+ # that does not carry them is refused rather than half-run.
215
+ - code: fhir-notification
216
+ params_from_payload:
217
+ capture: "$.entry.1.resource"
218
+ events_since_start: "$.entry.0.resource.eventsSinceSubscriptionStart"
@@ -0,0 +1,32 @@
1
+ # Inbound examples
2
+
3
+ Eight pipelines going the same direction: an outside system, a transform, DHIS2. They are
4
+ authored here rather than in a pack because each one spans several -- the http, storage,
5
+ transform and parquet blocks the runtime contributes, feeding the blocks `dirigent-dhis2`
6
+ contributes -- and no single pack can hold, or test, a pipeline that needs them all.
7
+
8
+ Every one of them:
9
+
10
+ - names every block it uses under `requires: blocks:`, so an environment missing one refuses
11
+ the document rather than failing halfway through a run;
12
+ - carries its DHIS2 connection in the document, pointed at the public play server, so
13
+ `dg run --local <file>` works with nothing applied and nothing configured;
14
+ - imports with `dry_run` defaulting to true. The play server is shared: leave it that way
15
+ there, and turn it off against an instance of your own;
16
+ - uses ids that exist on the demo. `WUg3MYWQ7pt` (Total Population, yearly) and
17
+ `x0PshcPLSk1` / `wZqi8EXN5x4` (PMTCT monthly) at Ngelehun CHC, `DiszpKrYNg8`, are the
18
+ defaults, because those are elements the demo will actually accept values for.
19
+
20
+ The two that read object storage need an `s3` connection bound to the scheme as an instance
21
+ setting (`storage_connections: {s3: <code>}`); the rest need only a network.
22
+
23
+ | Example | What it teaches |
24
+ | --- | --- |
25
+ | [`http-json-to-data-values.yaml`](http-json-to-data-values.yaml) | The shortest inbound path: pull a public JSON API, reshape it, gate it on DHIS2's own id and period formats, import it as a dry run. |
26
+ | [`csv-drop-to-data-values.yaml`](csv-drop-to-data-values.yaml) | The file drop: wait for a csv to land in a bucket, decode it, translate the sender's codes to uids through a lookup, drop the rows that carry no measurement. |
27
+ | [`webhook-payload-to-tracker-event.yaml`](webhook-payload-to-tracker-event.yaml) | Push instead of poll: a webhook maps one posted encounter onto parameters, and that mapping is the whole security boundary. |
28
+ | [`parquet-lakehouse-to-dhis2.yaml`](parquet-lakehouse-to-dhis2.yaml) | Grain: a parquet table of encounter-level rows reduced to the figures DHIS2 stores, then a completeness sensor that observes rather than registers. |
29
+ | [`fhir-observations-to-data-values.yaml`](fhir-observations-to-data-values.yaml) | Two standards meeting: LOINC codes mapped onto data elements, instants truncated to periods, and the org unit the payload cannot supply. |
30
+ | [`weekly-window-pull-and-import.yaml`](weekly-window-pull-and-import.yaml) | The window: a schedule whose runs pull the week that closed, `${run.window.start}` to `${run.window.end}`, so a late run and a backfill read the same week. |
31
+ | [`fan-out-per-facility-import.yaml`](fan-out-per-facility-import.yaml) | One call per facility with `items: continue`, why the fan converges before the import, and a reconciliation of asked-for against answered. |
32
+ | [`outside-to-dhis2-with-checks.yaml`](outside-to-dhis2-with-checks.yaml) | Every guard at once: a readiness sensor, a schema on what arrived, a schema on what was built, a rehearsed import, and the real one behind it. |