dirigent-integration 0.26.0__tar.gz → 0.27.0__tar.gz
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.
- {dirigent_integration-0.26.0 → dirigent_integration-0.27.0}/PKG-INFO +5 -5
- {dirigent_integration-0.26.0 → dirigent_integration-0.27.0}/README.md +3 -3
- {dirigent_integration-0.26.0 → dirigent_integration-0.27.0}/pyproject.toml +4 -2
- {dirigent_integration-0.26.0 → dirigent_integration-0.27.0}/pyproject.toml.orig +4 -2
- {dirigent_integration-0.26.0 → dirigent_integration-0.27.0}/src/dirigent_integration/shelves/fhir/README.md +1 -1
- {dirigent_integration-0.26.0 → dirigent_integration-0.27.0}/src/dirigent_integration/shelves/fhir/fhir-questionnaire-response-to-data-values.yaml +1 -1
- dirigent_integration-0.27.0/src/dirigent_integration/shelves/fhir/fhir-subscription-to-dhis2.yaml +249 -0
- {dirigent_integration-0.26.0 → dirigent_integration-0.27.0}/src/dirigent_integration/shelves/inbound/README.md +1 -1
- dirigent_integration-0.26.0/src/dirigent_integration/shelves/inbound/webhook-payload-to-tracker-event.yaml → dirigent_integration-0.27.0/src/dirigent_integration/shelves/inbound/endpoint-payload-to-tracker-event.yaml +72 -56
- dirigent_integration-0.26.0/src/dirigent_integration/shelves/fhir/fhir-subscription-webhook-to-dhis2.yaml +0 -218
- {dirigent_integration-0.26.0 → dirigent_integration-0.27.0}/LICENSE +0 -0
- {dirigent_integration-0.26.0 → dirigent_integration-0.27.0}/src/dirigent_integration/__init__.py +0 -0
- {dirigent_integration-0.26.0 → dirigent_integration-0.27.0}/src/dirigent_integration/py.typed +0 -0
- {dirigent_integration-0.26.0 → dirigent_integration-0.27.0}/src/dirigent_integration/shelves/dhis2-analytics-to-csv-report.yaml +0 -0
- {dirigent_integration-0.26.0 → dirigent_integration-0.27.0}/src/dirigent_integration/shelves/dhis2-export-to-s3.yaml +0 -0
- {dirigent_integration-0.26.0 → dirigent_integration-0.27.0}/src/dirigent_integration/shelves/dhis2-metadata-snapshot.yaml +0 -0
- {dirigent_integration-0.26.0 → dirigent_integration-0.27.0}/src/dirigent_integration/shelves/dhis2-tracker-weekly-window.yaml +0 -0
- {dirigent_integration-0.26.0 → dirigent_integration-0.27.0}/src/dirigent_integration/shelves/dhis2-values-per-org-unit-to-parquet.yaml +0 -0
- {dirigent_integration-0.26.0 → dirigent_integration-0.27.0}/src/dirigent_integration/shelves/dhis2-values-to-parquet.yaml +0 -0
- {dirigent_integration-0.26.0 → dirigent_integration-0.27.0}/src/dirigent_integration/shelves/fhir/dhis2-to-fhir-observations.yaml +0 -0
- {dirigent_integration-0.26.0 → dirigent_integration-0.27.0}/src/dirigent_integration/shelves/fhir/fhir-capture-bundle-to-data-values.yaml +0 -0
- {dirigent_integration-0.26.0 → dirigent_integration-0.27.0}/src/dirigent_integration/shelves/fhir/fhir-conceptmap-driven-mapping.yaml +0 -0
- {dirigent_integration-0.26.0 → dirigent_integration-0.27.0}/src/dirigent_integration/shelves/fhir/fhir-encounter-to-event.yaml +0 -0
- {dirigent_integration-0.26.0 → dirigent_integration-0.27.0}/src/dirigent_integration/shelves/fhir/fhir-measure-report-to-analytics-check.yaml +0 -0
- {dirigent_integration-0.26.0 → dirigent_integration-0.27.0}/src/dirigent_integration/shelves/fhir/fhir-nightly-window-sync.yaml +0 -0
- {dirigent_integration-0.26.0 → dirigent_integration-0.27.0}/src/dirigent_integration/shelves/fhir/fhir-patient-to-tracked-entity.yaml +0 -0
- {dirigent_integration-0.26.0 → dirigent_integration-0.27.0}/src/dirigent_integration/shelves/inbound/csv-drop-to-data-values.yaml +0 -0
- {dirigent_integration-0.26.0 → dirigent_integration-0.27.0}/src/dirigent_integration/shelves/inbound/fan-out-per-facility-import.yaml +0 -0
- {dirigent_integration-0.26.0 → dirigent_integration-0.27.0}/src/dirigent_integration/shelves/inbound/fhir-observations-to-data-values.yaml +0 -0
- {dirigent_integration-0.26.0 → dirigent_integration-0.27.0}/src/dirigent_integration/shelves/inbound/http-json-to-data-values.yaml +0 -0
- {dirigent_integration-0.26.0 → dirigent_integration-0.27.0}/src/dirigent_integration/shelves/inbound/outside-to-dhis2-with-checks.yaml +0 -0
- {dirigent_integration-0.26.0 → dirigent_integration-0.27.0}/src/dirigent_integration/shelves/inbound/parquet-lakehouse-to-dhis2.yaml +0 -0
- {dirigent_integration-0.26.0 → dirigent_integration-0.27.0}/src/dirigent_integration/shelves/inbound/weekly-window-pull-and-import.yaml +0 -0
- {dirigent_integration-0.26.0 → dirigent_integration-0.27.0}/src/dirigent_integration/shelves/parquet-to-dhis2-import.yaml +0 -0
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dirigent-integration
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.27.0
|
|
4
4
|
Summary: The control center for the dirigent plugin ecosystem: assemble every pack, run everyone's tests and examples together.
|
|
5
5
|
License-Expression: LicenseRef-Proprietary
|
|
6
6
|
License-File: LICENSE
|
|
7
|
-
Requires-Dist: dirigent-plugin==0.
|
|
7
|
+
Requires-Dist: dirigent-plugin==0.27.0
|
|
8
8
|
Requires-Python: >=3.13
|
|
9
9
|
Description-Content-Type: text/markdown
|
|
10
10
|
|
|
@@ -160,8 +160,8 @@ vocabulary the shelf is built on -- subject, capture, ConceptMap.
|
|
|
160
160
|
`dhis2.analytics_query` for the same period, and the gap delivered by `webhook.post`.
|
|
161
161
|
- **`examples/fhir/dhis2-to-fhir-observations.yaml`** -- the outbound leg: a data value set
|
|
162
162
|
published as a transaction `Bundle` of conditionally-updated `Observation`s.
|
|
163
|
-
- **`examples/fhir/fhir-subscription-
|
|
164
|
-
|
|
163
|
+
- **`examples/fhir/fhir-subscription-to-dhis2.yaml`** -- the subscription contract, landing on
|
|
164
|
+
an endpoint source whose first step picks the only fields any later step sees.
|
|
165
165
|
- **`examples/fhir/fhir-nightly-window-sync.yaml`** -- a half-open `_lastUpdated` window, a
|
|
166
166
|
fan-out per resource type under `items: continue`, and a join that says what it missed.
|
|
167
167
|
|
|
@@ -169,7 +169,7 @@ vocabulary the shelf is built on -- subject, capture, ConceptMap.
|
|
|
169
169
|
|
|
170
170
|
`examples/inbound/` is a shelf of eight pipelines going one direction -- an outside system, a
|
|
171
171
|
transform, DHIS2 -- teaching the shapes an inbound integration actually takes: a public JSON
|
|
172
|
-
API, a csv dropped in a bucket,
|
|
172
|
+
API, a csv dropped in a bucket, an encounter pushed to an endpoint, a parquet table from a lakehouse, a FHIR
|
|
173
173
|
Observation feed, a windowed weekly pull, a fan-out across facilities, and the defensive
|
|
174
174
|
version with every guard on. Each names every block it needs, carries its DHIS2 connection
|
|
175
175
|
pointed at the public play server, and imports as a dry run by default, so
|
|
@@ -150,8 +150,8 @@ vocabulary the shelf is built on -- subject, capture, ConceptMap.
|
|
|
150
150
|
`dhis2.analytics_query` for the same period, and the gap delivered by `webhook.post`.
|
|
151
151
|
- **`examples/fhir/dhis2-to-fhir-observations.yaml`** -- the outbound leg: a data value set
|
|
152
152
|
published as a transaction `Bundle` of conditionally-updated `Observation`s.
|
|
153
|
-
- **`examples/fhir/fhir-subscription-
|
|
154
|
-
|
|
153
|
+
- **`examples/fhir/fhir-subscription-to-dhis2.yaml`** -- the subscription contract, landing on
|
|
154
|
+
an endpoint source whose first step picks the only fields any later step sees.
|
|
155
155
|
- **`examples/fhir/fhir-nightly-window-sync.yaml`** -- a half-open `_lastUpdated` window, a
|
|
156
156
|
fan-out per resource type under `items: continue`, and a join that says what it missed.
|
|
157
157
|
|
|
@@ -159,7 +159,7 @@ vocabulary the shelf is built on -- subject, capture, ConceptMap.
|
|
|
159
159
|
|
|
160
160
|
`examples/inbound/` is a shelf of eight pipelines going one direction -- an outside system, a
|
|
161
161
|
transform, DHIS2 -- teaching the shapes an inbound integration actually takes: a public JSON
|
|
162
|
-
API, a csv dropped in a bucket,
|
|
162
|
+
API, a csv dropped in a bucket, an encounter pushed to an endpoint, a parquet table from a lakehouse, a FHIR
|
|
163
163
|
Observation feed, a windowed weekly pull, a fan-out across facilities, and the defensive
|
|
164
164
|
version with every guard on. Each names every block it needs, carries its DHIS2 connection
|
|
165
165
|
pointed at the public play server, and imports as a dry run by default, so
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "dirigent-integration"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.27.0"
|
|
4
4
|
description = "The control center for the dirigent plugin ecosystem: assemble every pack, run everyone's tests and examples together."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.13"
|
|
7
7
|
license = "LicenseRef-Proprietary"
|
|
8
8
|
license-files = ["LICENSE"]
|
|
9
|
-
dependencies = ["dirigent-plugin==0.
|
|
9
|
+
dependencies = ["dirigent-plugin==0.27.0"]
|
|
10
10
|
|
|
11
11
|
[project.entry-points."dirigent.plugins.v1"]
|
|
12
12
|
integration = "dirigent_integration:plugin"
|
|
@@ -34,10 +34,12 @@ assembly = [
|
|
|
34
34
|
]
|
|
35
35
|
dev = [
|
|
36
36
|
"dirigent-testing",
|
|
37
|
+
"mypy>=2",
|
|
37
38
|
"pytest>=8",
|
|
38
39
|
"pytest-asyncio>=0.24",
|
|
39
40
|
"pyyaml>=6",
|
|
40
41
|
"ruff>=0.16",
|
|
42
|
+
"types-pyyaml>=6",
|
|
41
43
|
]
|
|
42
44
|
|
|
43
45
|
[tool.uv]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "dirigent-integration"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.27.0"
|
|
4
4
|
description = "The control center for the dirigent plugin ecosystem: assemble every pack, run everyone's tests and examples together."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.13"
|
|
@@ -10,7 +10,7 @@ license-files = ["LICENSE"]
|
|
|
10
10
|
# A published install carries the cross-boundary shelves and nothing else: each document
|
|
11
11
|
# names the packs it needs under requires, and the Examples screen marks what is missing.
|
|
12
12
|
dependencies = [
|
|
13
|
-
"dirigent-plugin==0.
|
|
13
|
+
"dirigent-plugin==0.27.0",
|
|
14
14
|
]
|
|
15
15
|
|
|
16
16
|
# dirigent-testing is dev-only: the packs' suites import it, and nothing at runtime does,
|
|
@@ -41,10 +41,12 @@ assembly = [
|
|
|
41
41
|
]
|
|
42
42
|
dev = [
|
|
43
43
|
"dirigent-testing",
|
|
44
|
+
"mypy>=2",
|
|
44
45
|
"pytest>=8",
|
|
45
46
|
"pytest-asyncio>=0.24",
|
|
46
47
|
"pyyaml>=6",
|
|
47
48
|
"ruff>=0.16",
|
|
49
|
+
"types-pyyaml>=6",
|
|
48
50
|
]
|
|
49
51
|
|
|
50
52
|
# All component packages ride their tracked branch, unpinned: the integration always assembles
|
|
@@ -60,7 +60,7 @@ option set is in the regenerated map, and the pipeline that reads it needs no ed
|
|
|
60
60
|
| [fhir-conceptmap-driven-mapping.yaml](fhir-conceptmap-driven-mapping.yaml) | The mapping fetched rather than written: a `ConceptMap` flattened into a lookup at run time, applied to coded answers, with `equivalence` honoured so an inexact match is reported instead of imported. |
|
|
61
61
|
| [fhir-measure-report-to-analytics-check.yaml](fhir-measure-report-to-analytics-check.yaml) | A reconciliation that writes nothing: a `MeasureReport` held against `dhis2.analytics_query` for the same period and org unit, and the gap delivered by `webhook.post` whether or not there is one. |
|
|
62
62
|
| [dhis2-to-fhir-observations.yaml](dhis2-to-fhir-observations.yaml) | The outbound direction: a data value set published as a transaction `Bundle` of `Observation`s, each a conditional update keyed on the aggregate cell's natural key so republishing updates rather than duplicates. |
|
|
63
|
-
| [fhir-subscription-
|
|
63
|
+
| [fhir-subscription-to-dhis2.yaml](fhir-subscription-to-dhis2.yaml) | The subscription contract -- `criteria`, `channel.type`, `channel.endpoint`, and what `channel.payload` decides -- landing on an endpoint source the FHIR server calls with a fixed `Authorization` header, whose first step unwraps the notification so strictly that nothing it does not name reaches a later step. |
|
|
64
64
|
| [fhir-nightly-window-sync.yaml](fhir-nightly-window-sync.yaml) | The interval a firing covers rather than the moment it fired at: a half-open `_lastUpdated` range, a fan-out per resource type under `items: continue`, and a join under `rule: all_done` that says what it did not reach. |
|
|
65
65
|
|
|
66
66
|
## Running them
|
|
@@ -93,7 +93,7 @@ params:
|
|
|
93
93
|
|
|
94
94
|
steps:
|
|
95
95
|
# The capture, written inline so the document runs with nothing set up. In service it is the
|
|
96
|
-
# body of
|
|
96
|
+
# body of an endpoint delivery (fhir-subscription-to-dhis2.yaml) or one entry of a
|
|
97
97
|
# searchset Bundle (capture-bundle-to-data-values.yaml); either way this step's output is
|
|
98
98
|
# exactly one QuestionnaireResponse, and nothing downstream knows where it came from.
|
|
99
99
|
receive:
|
dirigent_integration-0.27.0/src/dirigent_integration/shelves/fhir/fhir-subscription-to-dhis2.yaml
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
# A FHIR Subscription notification delivered to an endpoint source, 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 endpoint; the request becomes the
|
|
5
|
+
# source's output, `unwrap` picks the notified resource out of its body, the document translates
|
|
6
|
+
# it into a data value set, and the 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
|
+
# endpoint source takes; the others are websocket, email, and message.
|
|
15
|
+
# channel.endpoint where to POST it -- this source's route on the instance,
|
|
16
|
+
# `$DG_URL/endpoints/fhir/notifications`.
|
|
17
|
+
#
|
|
18
|
+
# HOW THE SERVER PRESENTS THE TOKEN. A rest-hook subscription can carry fixed headers that the
|
|
19
|
+
# server sends with every notification: `channel.header` in R4, a `parameter` named for the
|
|
20
|
+
# header in R5. Set one to `Authorization: Bearer <token>`, with the token the apply printed,
|
|
21
|
+
# and every delivery authenticates as this source. A server that cannot send headers cannot
|
|
22
|
+
# reach it, which is the point: the endpoint takes no anonymous POST.
|
|
23
|
+
#
|
|
24
|
+
# What arrives in that POST depends on `channel.payload`, and the three choices are a real
|
|
25
|
+
# decision rather than a formality:
|
|
26
|
+
#
|
|
27
|
+
# absent a PING. An empty body: "something matched, come and look". The pipeline
|
|
28
|
+
# then has to search the server itself, which is more work and is also the
|
|
29
|
+
# only choice that never puts patient data in a request body.
|
|
30
|
+
# id-only the notification names the resource; the pipeline reads it back.
|
|
31
|
+
# full-resource the resource rides in the notification. One request, no read-back, and
|
|
32
|
+
# the delivery is now carrying clinical data -- so it is signed, and the
|
|
33
|
+
# endpoint is not on the public internet.
|
|
34
|
+
#
|
|
35
|
+
# This document takes full-resource, because it is the shape that shows the whole translation
|
|
36
|
+
# in one file. In R4 that body is the bare resource; R5 and the R4 backport wrap it in a
|
|
37
|
+
# history Bundle whose first entry is a SubscriptionStatus and whose second is the resource,
|
|
38
|
+
# which is the shape mapped below and the one most servers now send.
|
|
39
|
+
#
|
|
40
|
+
# `d2w fhir serve` HAS NO SUBSCRIPTION. Its capture surface is `POST /QuestionnaireResponse`
|
|
41
|
+
# and its notification surface is the receipt spool -- `GET /facade/spool`, whose lifecycle
|
|
42
|
+
# moves received -> forwarded or rejected. A pipeline pulls that spool on a schedule instead
|
|
43
|
+
# (fhir-nightly-window-sync.yaml is that shape). This document is for the FHIR server that DOES
|
|
44
|
+
# support Subscription, which is most of them, and it is written so the same translation serves
|
|
45
|
+
# either way.
|
|
46
|
+
#
|
|
47
|
+
# WHAT ARRIVES. The notification body:
|
|
48
|
+
#
|
|
49
|
+
# {"resourceType": "Bundle", "type": "history",
|
|
50
|
+
# "entry": [
|
|
51
|
+
# {"resource": {"resourceType": "SubscriptionStatus", "type": "event-notification",
|
|
52
|
+
# "eventsSinceSubscriptionStart": "17"}},
|
|
53
|
+
# {"resource": {"resourceType": "QuestionnaireResponse", "status": "completed",
|
|
54
|
+
# "questionnaire": ".../Questionnaire/lyLU2wR22tC",
|
|
55
|
+
# "subject": {"reference": "Location/dczh6Jfd4no"},
|
|
56
|
+
# "extension": [{"url": ".../d2-period",
|
|
57
|
+
# "extension": [{"url": "iso", "valueString": "202608"}]}],
|
|
58
|
+
# "item": [...]}}]}
|
|
59
|
+
#
|
|
60
|
+
# WHAT LEAVES. The `/api/dataValueSets` envelope, same as every inbound document here:
|
|
61
|
+
#
|
|
62
|
+
# {"dataSet": "lyLU2wR22tC", "period": "202608", "orgUnit": "dczh6Jfd4no",
|
|
63
|
+
# "dataValues": [{"dataElement": "GMd99K8gVut",
|
|
64
|
+
# "categoryOptionCombo": "qNCMOhkoQju", "value": "426.3"}]}
|
|
65
|
+
#
|
|
66
|
+
# WHERE A READER CHANGES IT. The `unwrap` step, and nothing else. It is STRICT: it names two
|
|
67
|
+
# paths into the body, a body that does not have them fails the run before anything is
|
|
68
|
+
# translated, and nothing else the caller POSTs reaches a later step, because no later step
|
|
69
|
+
# reads the body. A server sending the bare R4 resource rather than a Bundle changes `capture`
|
|
70
|
+
# to the body itself, and the counter, which a bare resource does not carry, to null.
|
|
71
|
+
#
|
|
72
|
+
# WHAT LIVES ON THE INSTANCE AND NOT IN GIT. The source's token, whether it is paused, and
|
|
73
|
+
# every input it has taken. The apply shows the token exactly once, and
|
|
74
|
+
# `dg source rotate-token` mints another, which the Subscription's header then has to carry.
|
|
75
|
+
#
|
|
76
|
+
# RUN IT BY HAND, here, with no server. No --input takes the source's sample, a whole
|
|
77
|
+
# notification Bundle:
|
|
78
|
+
#
|
|
79
|
+
# dg run --local examples/fhir/fhir-subscription-to-dhis2.yaml
|
|
80
|
+
#
|
|
81
|
+
# ON A SERVER, apply it, keep the token the apply printed, and deliver a notification the way
|
|
82
|
+
# the FHIR server would:
|
|
83
|
+
#
|
|
84
|
+
# dg apply examples/fhir/fhir-subscription-to-dhis2.yaml
|
|
85
|
+
# dg source call fhir-subscription-to-dhis2 notified --source-token "$TOKEN" --body @notification.json
|
|
86
|
+
# curl -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/fhir+json' \
|
|
87
|
+
# --data @notification.json "$DG_URL/endpoints/fhir/notifications"
|
|
88
|
+
# dg source inputs fhir-subscription-to-dhis2 notified
|
|
89
|
+
#
|
|
90
|
+
# EXPECT THIS RUN TO SUCCEED with network access to the play server, the import a dry run.
|
|
91
|
+
|
|
92
|
+
format: dirigent/v1
|
|
93
|
+
kind: pipeline
|
|
94
|
+
code: fhir-subscription-to-dhis2
|
|
95
|
+
name: A FHIR Subscription notification imported
|
|
96
|
+
description: |
|
|
97
|
+
Take a FHIR `Subscription` rest-hook notification on an endpoint source, translate the
|
|
98
|
+
notified `QuestionnaireResponse` into a DHIS2 data value set, and rehearse the import.
|
|
99
|
+
|
|
100
|
+
The subscription contract is three fields: `criteria` (a search string), `channel.type`
|
|
101
|
+
(`rest-hook` is an HTTP POST), and `channel.endpoint` (the source's route). The server
|
|
102
|
+
presents the source's token as a fixed `Authorization` header on the subscription.
|
|
103
|
+
`channel.payload` decides whether the delivery is a ping, an id, or the resource itself --
|
|
104
|
+
this takes the resource, wrapped in the history `Bundle` R5 and the R4 backport send.
|
|
105
|
+
|
|
106
|
+
tags: [fhir, dhis2, sources, transform, jq, cross-boundary]
|
|
107
|
+
|
|
108
|
+
requires:
|
|
109
|
+
blocks:
|
|
110
|
+
- transform.jq
|
|
111
|
+
- dhis2.data_value_set_import
|
|
112
|
+
|
|
113
|
+
connections:
|
|
114
|
+
dhis2-demo:
|
|
115
|
+
kind: dhis2
|
|
116
|
+
config:
|
|
117
|
+
base_url: https://play.dhis2.org/2.43
|
|
118
|
+
basic_username: admin
|
|
119
|
+
basic_password: district
|
|
120
|
+
timeout: 60s
|
|
121
|
+
|
|
122
|
+
steps:
|
|
123
|
+
notified:
|
|
124
|
+
source:
|
|
125
|
+
kind: endpoint
|
|
126
|
+
path: /fhir/notifications
|
|
127
|
+
methods: [POST]
|
|
128
|
+
accepts: [application/fhir+json, application/json]
|
|
129
|
+
max_body: 1MB
|
|
130
|
+
# What a delivery carries: the history Bundle, a SubscriptionStatus and then the resource.
|
|
131
|
+
sample:
|
|
132
|
+
content_type: application/fhir+json
|
|
133
|
+
body:
|
|
134
|
+
resourceType: Bundle
|
|
135
|
+
type: history
|
|
136
|
+
entry:
|
|
137
|
+
- resource:
|
|
138
|
+
resourceType: SubscriptionStatus
|
|
139
|
+
type: event-notification
|
|
140
|
+
eventsSinceSubscriptionStart: "17"
|
|
141
|
+
- resource:
|
|
142
|
+
resourceType: QuestionnaireResponse
|
|
143
|
+
status: completed
|
|
144
|
+
authored: "2026-08-31T09:00:00Z"
|
|
145
|
+
questionnaire: http://example.org/fhir/Questionnaire/lyLU2wR22tC
|
|
146
|
+
subject:
|
|
147
|
+
reference: Location/dczh6Jfd4no
|
|
148
|
+
extension:
|
|
149
|
+
- url: http://example.org/fhir/StructureDefinition/d2-period
|
|
150
|
+
extension:
|
|
151
|
+
- url: iso
|
|
152
|
+
valueString: "202608"
|
|
153
|
+
- url: type
|
|
154
|
+
valueCode: Monthly
|
|
155
|
+
- url: http://example.org/fhir/StructureDefinition/d2-attribute-option-combo
|
|
156
|
+
valueCoding:
|
|
157
|
+
code: N7QFN41eTN8
|
|
158
|
+
item:
|
|
159
|
+
- linkId: GMd99K8gVut
|
|
160
|
+
item:
|
|
161
|
+
- linkId: GMd99K8gVut.qNCMOhkoQju
|
|
162
|
+
answer:
|
|
163
|
+
- valueDecimal: 426.3
|
|
164
|
+
- linkId: GMd99K8gVut.LbeIlyHEhKr
|
|
165
|
+
answer:
|
|
166
|
+
- valueDecimal: 628.2
|
|
167
|
+
- linkId: wfKKFhBn0Q0
|
|
168
|
+
answer:
|
|
169
|
+
- valueDecimal: 142.1
|
|
170
|
+
|
|
171
|
+
# The whole declaration of what a delivery may carry. `capture` is the history Bundle's second
|
|
172
|
+
# entry -- the first is the SubscriptionStatus -- and the counter comes off that status. A gap
|
|
173
|
+
# between two deliveries' counters means a notification was lost, which is the only way this
|
|
174
|
+
# channel says so.
|
|
175
|
+
unwrap:
|
|
176
|
+
block: transform.jq
|
|
177
|
+
depends_on: [notified]
|
|
178
|
+
config:
|
|
179
|
+
input: "${steps.notified.output.body}"
|
|
180
|
+
program: |
|
|
181
|
+
if .resourceType != "Bundle" then error("the notification is not a Bundle")
|
|
182
|
+
elif (.entry[1].resource.resourceType // null) != "QuestionnaireResponse"
|
|
183
|
+
then error("the notification's second entry is not a QuestionnaireResponse")
|
|
184
|
+
elif (.entry[0].resource.eventsSinceSubscriptionStart | type) != "string"
|
|
185
|
+
then error("the notification carries no SubscriptionStatus counter")
|
|
186
|
+
else {capture: .entry[1].resource,
|
|
187
|
+
events_since_start: .entry[0].resource.eventsSinceSubscriptionStart}
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Only a completed response is translated. An in-progress one is a form still being filled,
|
|
191
|
+
# and a notification is allowed to fire on it -- `criteria` matches a resource, not a state.
|
|
192
|
+
translate:
|
|
193
|
+
block: transform.jq
|
|
194
|
+
depends_on: [unwrap]
|
|
195
|
+
config:
|
|
196
|
+
input: "${steps.unwrap.output.value.capture}"
|
|
197
|
+
program: |
|
|
198
|
+
if .status != "completed" then
|
|
199
|
+
error("the notified response is \(.status), not completed; nothing to import")
|
|
200
|
+
else . end
|
|
201
|
+
| . as $r
|
|
202
|
+
| {dataSet: ($r.questionnaire | split("/") | last),
|
|
203
|
+
period: ([$r.extension[] | select(.url | endswith("/d2-period")).extension[]
|
|
204
|
+
| select(.url == "iso").valueString][0]),
|
|
205
|
+
orgUnit: ($r.subject.reference | ltrimstr("Location/")),
|
|
206
|
+
attributeOptionCombo: (
|
|
207
|
+
[$r.extension[] | select(.url | endswith("/d2-attribute-option-combo"))][0]
|
|
208
|
+
| .valueCoding.code),
|
|
209
|
+
dataValues: [
|
|
210
|
+
$r.item[] | recurse(.item[]?)
|
|
211
|
+
| select((.answer // []) | length > 0)
|
|
212
|
+
| (.linkId | split(".")) as $key
|
|
213
|
+
| (.answer[0] | (.valueDecimal // .valueInteger // .valueString // .valueBoolean)) as $v
|
|
214
|
+
| {dataElement: $key[0], value: ($v | tostring)}
|
|
215
|
+
+ (if ($key | length) > 1 then {categoryOptionCombo: $key[1]} else {} end)]}
|
|
216
|
+
| del(.attributeOptionCombo | select(. == null))
|
|
217
|
+
|
|
218
|
+
# A notification arrives once and is not replayed, so the import's retry budget is what
|
|
219
|
+
# stands between a momentary DHIS2 and a lost capture. dry_run makes this a rehearsal;
|
|
220
|
+
# dropping it is the whole difference between this document and a live forwarder.
|
|
221
|
+
rehearse:
|
|
222
|
+
block: dhis2.data_value_set_import
|
|
223
|
+
depends_on: [translate]
|
|
224
|
+
retry:
|
|
225
|
+
# The budget counts the first try, so this is one attempt and two retries.
|
|
226
|
+
max_attempts: 3
|
|
227
|
+
backoff: 5s
|
|
228
|
+
config:
|
|
229
|
+
connection: dhis2-demo
|
|
230
|
+
data_values: "${steps.translate.output.value}"
|
|
231
|
+
dry_run: true
|
|
232
|
+
atomic_mode: ALL
|
|
233
|
+
|
|
234
|
+
receipt:
|
|
235
|
+
block: transform.jq
|
|
236
|
+
depends_on: [rehearse]
|
|
237
|
+
config:
|
|
238
|
+
input:
|
|
239
|
+
summary: "${steps.rehearse.output}"
|
|
240
|
+
events_since_start: "${steps.unwrap.output.value.events_since_start}"
|
|
241
|
+
document: "${steps.translate.output.value}"
|
|
242
|
+
program: |
|
|
243
|
+
{notification: .events_since_start,
|
|
244
|
+
data_set: .document.dataSet,
|
|
245
|
+
period: .document.period,
|
|
246
|
+
org_unit: .document.orgUnit,
|
|
247
|
+
status: .summary.status,
|
|
248
|
+
would_import: .summary.imported,
|
|
249
|
+
conflicts: (.summary.conflicts | length)}
|
|
@@ -24,7 +24,7 @@ setting (`storage_connections: {s3: <code>}`); the rest need only a network.
|
|
|
24
24
|
| --- | --- |
|
|
25
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
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
|
-
| [`
|
|
27
|
+
| [`endpoint-payload-to-tracker-event.yaml`](endpoint-payload-to-tracker-event.yaml) | Push instead of poll: an endpoint source takes one posted encounter, and the jq step that picks its named fields is the whole security boundary. |
|
|
28
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
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
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. |
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
# An event posted by an outside system, landing in DHIS2 the moment it is captured.
|
|
2
2
|
#
|
|
3
3
|
# The other examples on this shelf poll or wait. This one is pushed: a mobile capture app,
|
|
4
|
-
# an EMR, a lab middleware POSTs one encounter to this pipeline's
|
|
5
|
-
# with that
|
|
6
|
-
# scanned.
|
|
4
|
+
# an EMR, a lab middleware POSTs one encounter to this pipeline's endpoint and the run starts
|
|
5
|
+
# with that request as its source's output. Nothing is scheduled and nothing is scanned.
|
|
7
6
|
#
|
|
8
7
|
# WHERE THIS LANDS, AND WHY IT IS NOT A TRACKER EVENT. An event-shaped capture is what
|
|
9
8
|
# arrives, but `dhis2.tracker` in this pack READS `/api/tracker` -- tracked entities,
|
|
@@ -11,51 +10,61 @@
|
|
|
11
10
|
# today. The encounter is therefore summed into the aggregate figures it contributes to and
|
|
12
11
|
# imported as a data value set, which is what an aggregate DHIS2 wants from an event stream
|
|
13
12
|
# anyway. When a tracker write block lands, only the reshape and the import change; the
|
|
14
|
-
# intake, the mapping table and the guard do not.
|
|
13
|
+
# intake, the pick, the mapping table and the guard do not.
|
|
15
14
|
#
|
|
16
15
|
# The hops, and the shape at each one:
|
|
17
16
|
#
|
|
18
17
|
# 1. the POST -> {encounter: {facility, occurred_at, items: [{code, quantity}]}}
|
|
19
|
-
# 2.
|
|
18
|
+
# 2. pick -> {facility, occurred_at, items}, three paths into that body and
|
|
19
|
+
# nothing else
|
|
20
20
|
# 3. reshape -> {dataValues: [{dataElement, period, orgUnit, value}]}, one value per
|
|
21
21
|
# distinct item code, quantities summed
|
|
22
22
|
# 4. gate -> the same, refused unless the ids and the period are DHIS2's own
|
|
23
23
|
# 5. import -> the instance's summary
|
|
24
24
|
#
|
|
25
|
-
# THE
|
|
26
|
-
#
|
|
27
|
-
#
|
|
28
|
-
#
|
|
25
|
+
# THE PICK IS THE WHOLE SECURITY BOUNDARY. The whole body is the source's output, but only
|
|
26
|
+
# `pick` reads it, and it hands on exactly three named fields, each checked for its type. A
|
|
27
|
+
# payload carrying a connection code, a dry_run: false, or a field nobody asked for reaches
|
|
28
|
+
# no later step, because no path names it; `dry_run` is a parameter, the pipeline's own to
|
|
29
|
+
# decide, and no call can choose it.
|
|
29
30
|
#
|
|
30
31
|
# The period is derived rather than accepted. A capture knows when it happened; it does not
|
|
31
32
|
# get to say which DHIS2 month it belongs to, because that is the receiving instance's
|
|
32
33
|
# vocabulary and a caller that gets it wrong would silently write into the wrong month.
|
|
33
34
|
#
|
|
34
|
-
#
|
|
35
|
+
# RUN IT BY HAND, here, with no server. No --input takes the source's sample:
|
|
35
36
|
#
|
|
36
|
-
# dg run --local examples/inbound/
|
|
37
|
-
#
|
|
37
|
+
# dg run --local examples/inbound/endpoint-payload-to-tracker-event.yaml
|
|
38
|
+
# dg run --local examples/inbound/endpoint-payload-to-tracker-event.yaml --input '{body: {
|
|
39
|
+
# encounter: {facility: DiszpKrYNg8, occurred_at: "2026-06-14T09:20:00Z",
|
|
40
|
+
# items: [{code: NVP_WITHIN_72H, quantity: 3}]}}}'
|
|
38
41
|
#
|
|
39
|
-
#
|
|
42
|
+
# ON A SERVER, apply it, keep the token the apply printed, and post an encounter:
|
|
40
43
|
#
|
|
41
|
-
# dg apply examples/inbound/
|
|
42
|
-
# dg
|
|
43
|
-
#
|
|
44
|
-
# "facility": "DiszpKrYNg8", "occurred_at": "2026-06-14T09:20:00Z",
|
|
44
|
+
# dg apply examples/inbound/endpoint-payload-to-tracker-event.yaml
|
|
45
|
+
# dg source call endpoint-payload-to-tracker-event posted --source-token "$TOKEN" --body '{
|
|
46
|
+
# "encounter": {"facility": "DiszpKrYNg8", "occurred_at": "2026-06-14T09:20:00Z",
|
|
45
47
|
# "items": [{"code": "EXPOSED_REGISTERED", "quantity": 2}]}}'
|
|
48
|
+
# curl -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
|
|
49
|
+
# --data @encounter.json http://localhost:3333/endpoints/captures
|
|
50
|
+
# dg source inputs endpoint-payload-to-tracker-event posted
|
|
51
|
+
#
|
|
52
|
+
# The token lives on the instance and is shown once; `dg source rotate-token` mints another.
|
|
53
|
+
#
|
|
54
|
+
# EXPECT THIS RUN TO SUCCEED with network access to the play server, the import a dry run.
|
|
46
55
|
|
|
47
56
|
format: dirigent/v1
|
|
48
57
|
kind: pipeline
|
|
49
|
-
code:
|
|
58
|
+
code: endpoint-payload-to-tracker-event
|
|
50
59
|
name: A pushed encounter into DHIS2
|
|
51
60
|
description: |
|
|
52
|
-
An outside system POSTs one captured encounter
|
|
53
|
-
value set and imports it.
|
|
61
|
+
An outside system POSTs one captured encounter to an endpoint source; a jq step picks the
|
|
62
|
+
named fields, and the run reshapes them into a DHIS2 data value set and imports it.
|
|
54
63
|
|
|
55
64
|
The pack's `dhis2.tracker` block reads `/api/tracker` and does not write it, so an
|
|
56
65
|
event-shaped capture lands as the aggregate figures it contributes to.
|
|
57
66
|
|
|
58
|
-
tags: [inbound, dhis2,
|
|
67
|
+
tags: [inbound, dhis2, sources, transform, validate, cross-boundary]
|
|
59
68
|
|
|
60
69
|
requires:
|
|
61
70
|
blocks:
|
|
@@ -75,28 +84,6 @@ connections:
|
|
|
75
84
|
params:
|
|
76
85
|
type: object
|
|
77
86
|
properties:
|
|
78
|
-
facility:
|
|
79
|
-
type: string
|
|
80
|
-
description: The uid of the facility the encounter was captured at.
|
|
81
|
-
default: DiszpKrYNg8
|
|
82
|
-
occurred_at:
|
|
83
|
-
type: string
|
|
84
|
-
format: date-time
|
|
85
|
-
description: When the encounter happened, which is what the DHIS2 period is derived from.
|
|
86
|
-
default: "2026-06-14T09:20:00Z"
|
|
87
|
-
items:
|
|
88
|
-
type: array
|
|
89
|
-
description: What was given or done, in the sending system's own item codes.
|
|
90
|
-
default:
|
|
91
|
-
- { code: EXPOSED_REGISTERED, quantity: 2 }
|
|
92
|
-
- { code: NVP_WITHIN_72H, quantity: 1 }
|
|
93
|
-
- { code: EXPOSED_REGISTERED, quantity: 1 }
|
|
94
|
-
items:
|
|
95
|
-
type: object
|
|
96
|
-
required: [code, quantity]
|
|
97
|
-
properties:
|
|
98
|
-
code: { type: string }
|
|
99
|
-
quantity: { type: integer }
|
|
100
87
|
dry_run:
|
|
101
88
|
type: boolean
|
|
102
89
|
description: Whether the instance validates the import and writes nothing.
|
|
@@ -120,16 +107,55 @@ schemas:
|
|
|
120
107
|
value: { type: string }
|
|
121
108
|
|
|
122
109
|
steps:
|
|
110
|
+
posted:
|
|
111
|
+
source:
|
|
112
|
+
kind: endpoint
|
|
113
|
+
path: /captures
|
|
114
|
+
methods: [POST]
|
|
115
|
+
accepts: [application/json]
|
|
116
|
+
max_body: 64KB
|
|
117
|
+
sample:
|
|
118
|
+
body:
|
|
119
|
+
encounter:
|
|
120
|
+
facility: DiszpKrYNg8
|
|
121
|
+
occurred_at: "2026-06-14T09:20:00Z"
|
|
122
|
+
items:
|
|
123
|
+
- { code: EXPOSED_REGISTERED, quantity: 2 }
|
|
124
|
+
- { code: NVP_WITHIN_72H, quantity: 1 }
|
|
125
|
+
- { code: EXPOSED_REGISTERED, quantity: 1 }
|
|
126
|
+
|
|
127
|
+
# Three paths into the body, each one typed; anything else the caller sent stops here.
|
|
128
|
+
pick:
|
|
129
|
+
block: transform.jq
|
|
130
|
+
depends_on: [posted]
|
|
131
|
+
config:
|
|
132
|
+
input: "${steps.posted.output.body}"
|
|
133
|
+
program: |
|
|
134
|
+
.encounter as $e
|
|
135
|
+
| if ($e | type) != "object" then error("the body carries no encounter object")
|
|
136
|
+
elif ($e.facility | type) != "string" then error("encounter.facility must be a string")
|
|
137
|
+
elif ($e.occurred_at | type) != "string" then error("encounter.occurred_at must be a string")
|
|
138
|
+
elif ($e.items | type) != "array"
|
|
139
|
+
or ([$e.items[] | select((.code | type) != "string"
|
|
140
|
+
or (.quantity | type) != "number"
|
|
141
|
+
or .quantity != (.quantity | floor))] | length) > 0
|
|
142
|
+
then error("encounter.items must be a list of {code: string, quantity: integer}")
|
|
143
|
+
else {facility: $e.facility,
|
|
144
|
+
occurred_at: $e.occurred_at,
|
|
145
|
+
items: [$e.items[] | {code, quantity}]}
|
|
146
|
+
end
|
|
147
|
+
|
|
123
148
|
# One encounter can carry the same item twice -- two children registered in one visit --
|
|
124
149
|
# so the items are grouped and summed before they become data values. Importing them as
|
|
125
150
|
# two values would have the second silently replace the first rather than add to it.
|
|
126
151
|
reshape:
|
|
127
152
|
block: transform.jq
|
|
153
|
+
depends_on: [pick]
|
|
128
154
|
config:
|
|
129
155
|
input:
|
|
130
|
-
facility: "${
|
|
131
|
-
occurred_at: "${
|
|
132
|
-
items: "${
|
|
156
|
+
facility: "${steps.pick.output.value.facility}"
|
|
157
|
+
occurred_at: "${steps.pick.output.value.occurred_at}"
|
|
158
|
+
items: "${steps.pick.output.value.items}"
|
|
133
159
|
lookup:
|
|
134
160
|
EXPOSED_REGISTERED: x0PshcPLSk1
|
|
135
161
|
NVP_WITHIN_72H: wZqi8EXN5x4
|
|
@@ -164,13 +190,3 @@ steps:
|
|
|
164
190
|
connection: dhis2-demo
|
|
165
191
|
data_values: "${steps.gate.output.value}"
|
|
166
192
|
dry_run: "${params.dry_run}"
|
|
167
|
-
|
|
168
|
-
triggers:
|
|
169
|
-
webhooks:
|
|
170
|
-
- code: capture-posted
|
|
171
|
-
name: One encounter, pushed
|
|
172
|
-
description: The capture app POSTs an encounter as it is recorded.
|
|
173
|
-
params_from_payload:
|
|
174
|
-
facility: "$.encounter.facility"
|
|
175
|
-
occurred_at: "$.encounter.occurred_at"
|
|
176
|
-
items: "$.encounter.items"
|
|
@@ -1,218 +0,0 @@
|
|
|
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.dhis2.org/2.43
|
|
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"
|
|
File without changes
|
{dirigent_integration-0.26.0 → dirigent_integration-0.27.0}/src/dirigent_integration/__init__.py
RENAMED
|
File without changes
|
{dirigent_integration-0.26.0 → dirigent_integration-0.27.0}/src/dirigent_integration/py.typed
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|