Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5fd47e546 | ||
|
|
d76abf2df2 | ||
|
|
448896206f | ||
|
|
dc9ca2ceb2 |
@@ -219,6 +219,9 @@ jobs:
|
|||||||
- name: OpenZaak → NRC → Event Subscriber → projection-api
|
- name: OpenZaak → NRC → Event Subscriber → projection-api
|
||||||
id: projection
|
id: projection
|
||||||
run: make verify-projection
|
run: make verify-projection
|
||||||
|
- name: Objecten → NRC notification delivery
|
||||||
|
id: objecten_nrc
|
||||||
|
run: make verify-objecten-notifications
|
||||||
- name: Domain → Flowable → ACL → OpenZaak
|
- name: Domain → Flowable → ACL → OpenZaak
|
||||||
id: domain
|
id: domain
|
||||||
run: make verify-domain
|
run: make verify-domain
|
||||||
@@ -245,6 +248,7 @@ jobs:
|
|||||||
OBJECTTYPEN: ${{ steps.objecttypen.outcome }}
|
OBJECTTYPEN: ${{ steps.objecttypen.outcome }}
|
||||||
OBJECTEN: ${{ steps.objecten.outcome }}
|
OBJECTEN: ${{ steps.objecten.outcome }}
|
||||||
REGISTERRECORD: ${{ steps.registerrecord.outcome }}
|
REGISTERRECORD: ${{ steps.registerrecord.outcome }}
|
||||||
|
OBJECTEN_NOTIFICATIONS: ${{ steps.objecten_nrc.outcome }}
|
||||||
ACL: ${{ steps.acl.outcome }}
|
ACL: ${{ steps.acl.outcome }}
|
||||||
NRC: ${{ steps.nrc.outcome }}
|
NRC: ${{ steps.nrc.outcome }}
|
||||||
PROJECTION: ${{ steps.projection.outcome }}
|
PROJECTION: ${{ steps.projection.outcome }}
|
||||||
@@ -266,6 +270,7 @@ jobs:
|
|||||||
echo "| Objecttypen API + token | $(icon "$OBJECTTYPEN") |"
|
echo "| Objecttypen API + token | $(icon "$OBJECTTYPEN") |"
|
||||||
echo "| Objecten API + token | $(icon "$OBJECTEN") |"
|
echo "| Objecten API + token | $(icon "$OBJECTEN") |"
|
||||||
echo "| RegisterRecord objecttype | $(icon "$REGISTERRECORD") |"
|
echo "| RegisterRecord objecttype | $(icon "$REGISTERRECORD") |"
|
||||||
|
echo "| Objecten → NRC | $(icon "$OBJECTEN_NOTIFICATIONS") |"
|
||||||
echo "| ACL ↔ OpenZaak | $(icon "$ACL") |"
|
echo "| ACL ↔ OpenZaak | $(icon "$ACL") |"
|
||||||
echo "| OpenZaak → NRC | $(icon "$NRC") |"
|
echo "| OpenZaak → NRC | $(icon "$NRC") |"
|
||||||
echo "| NRC → Event Subscriber → projection | $(icon "$PROJECTION") |"
|
echo "| NRC → Event Subscriber → projection | $(icon "$PROJECTION") |"
|
||||||
@@ -285,7 +290,7 @@ jobs:
|
|||||||
# Log dump must precede teardown (which removes the containers).
|
# Log dump must precede teardown (which removes the containers).
|
||||||
- name: Dump container logs on failure
|
- name: Dump container logs on failure
|
||||||
if: failure()
|
if: failure()
|
||||||
run: docker compose -f infra/docker-compose.yml logs --no-color --tail=100 oz-init openzaak nrc-init nrc-web nrc-celery nrc-beat flowable-db flowable-rest flowable-init keycloak acl bff domain projection-db event-subscriber projection-api self-service openbaar behandel beheer objecttypen-db objecttypen-redis objecttypen-init objecttypen objecten-db objecten-redis objecten-init objecten registerrecord-init tempo prometheus grafana 2>&1 || true
|
run: docker compose -f infra/docker-compose.yml logs --no-color --tail=100 oz-init openzaak nrc-init nrc-web nrc-celery nrc-beat flowable-db flowable-rest flowable-init keycloak acl bff domain projection-db event-subscriber projection-api self-service openbaar behandel beheer objecttypen-db objecttypen-redis objecttypen-init objecttypen objecten-db objecten-redis objecten-init objecten objecten-celery registerrecord-init tempo prometheus grafana 2>&1 || true
|
||||||
- name: Tear down
|
- name: Tear down
|
||||||
if: always()
|
if: always()
|
||||||
run: make down
|
run: make down
|
||||||
|
|||||||
+3
-1
@@ -296,7 +296,9 @@ Split into independently deployable sub-slices (CLAUDE.md §13):
|
|||||||
Split into independently deployable sub-slices (CLAUDE.md §13):
|
Split into independently deployable sub-slices (CLAUDE.md §13):
|
||||||
|
|
||||||
- **S-19a** (#149, ✅) · ACL writes the `RegisterRecord` to Objecten on approval, idempotently, alongside the ZGW eindstatus. Carries the ADR (ADR-0028).
|
- **S-19a** (#149, ✅) · ACL writes the `RegisterRecord` to Objecten on approval, idempotently, alongside the ZGW eindstatus. Carries the ADR (ADR-0028).
|
||||||
- **S-19b** (#150) · Read projection sourced from Objecten instead of NRC zaak events. Depends on S-19a.
|
- **S-19b** (#150) · Read projection sourced from Objecten instead of NRC zaak events. *(split — #150 closed)*
|
||||||
|
- **S-19b-1** (#152) · Objecten publishes to NRC — broker, celery worker, `objecten` kanaal, notifications config. Turns back on what ADR-0028 deliberately disabled.
|
||||||
|
- **S-19b-2** (#153) · Projection derived from `RegisterRecord` objects, rebuildable from the Objecten-derived log. Depends on S-19b-1.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export DOCKER_HOST := unix://$(PODMAN_SOCK)
|
|||||||
endif
|
endif
|
||||||
endif
|
endif
|
||||||
|
|
||||||
.PHONY: ci lint build unit mutation frontend integration verify verify-up verify-acl verify-nrc verify-projection verify-bff verify-domain verify-observability verify-tracing verify-metrics verify-objecttypen verify-objecten verify-registerrecord verify-notifications smoke up down local verify-local local-down changelog openzaak-up openzaak-smoke openzaak-seed openzaak-down stack-up stack-smoke stack-down keycloak-up keycloak-smoke keycloak-down flowable-up flowable-smoke flowable-down help
|
.PHONY: ci lint build unit mutation frontend integration verify verify-up verify-acl verify-nrc verify-projection verify-bff verify-domain verify-observability verify-tracing verify-metrics verify-objecttypen verify-objecten verify-registerrecord verify-objecten-notifications verify-notifications smoke up down local verify-local local-down changelog openzaak-up openzaak-smoke openzaak-seed openzaak-down stack-up stack-smoke stack-down keycloak-up keycloak-smoke keycloak-down flowable-up flowable-smoke flowable-down help
|
||||||
|
|
||||||
## ci: run the full pipeline — lint, build, unit, mutation, frontend, verify (mirrors Gitea Actions)
|
## ci: run the full pipeline — lint, build, unit, mutation, frontend, verify (mirrors Gitea Actions)
|
||||||
## `verify` is the live-stack stage (full stack up once → ACL + notification checks).
|
## `verify` is the live-stack stage (full stack up once → ACL + notification checks).
|
||||||
@@ -201,6 +201,11 @@ verify-objecten:
|
|||||||
verify-registerrecord:
|
verify-registerrecord:
|
||||||
bash infra/run-registerrecord-check.sh
|
bash infra/run-registerrecord-check.sh
|
||||||
|
|
||||||
|
## verify-objecten-notifications: assert a RegisterRecord write in Objecten is DELIVERED as an
|
||||||
|
## `objecten` notification via NRC (S-19b-1), against the already-running stack.
|
||||||
|
verify-objecten-notifications:
|
||||||
|
bash infra/run-objecten-notifications-check.sh
|
||||||
|
|
||||||
## verify: local mirror of the CI verify-stack job — full stack up once, all checks,
|
## verify: local mirror of the CI verify-stack job — full stack up once, all checks,
|
||||||
## tear down (always). For fast single-concern local iteration use `integration`
|
## tear down (always). For fast single-concern local iteration use `integration`
|
||||||
## (oz-only) or `verify-notifications` (oz+nrc) instead.
|
## (oz-only) or `verify-notifications` (oz+nrc) instead.
|
||||||
@@ -212,6 +217,7 @@ verify:
|
|||||||
&& bash infra/run-acl-integration.sh \
|
&& bash infra/run-acl-integration.sh \
|
||||||
&& bash infra/run-notification-check.sh \
|
&& bash infra/run-notification-check.sh \
|
||||||
&& bash infra/run-projection-check.sh \
|
&& bash infra/run-projection-check.sh \
|
||||||
|
&& bash infra/run-objecten-notifications-check.sh \
|
||||||
&& bash infra/run-domain-check.sh \
|
&& bash infra/run-domain-check.sh \
|
||||||
&& bash infra/run-bff-check.sh \
|
&& bash infra/run-bff-check.sh \
|
||||||
&& bash infra/run-e2e-check.sh || rc=$$?; \
|
&& bash infra/run-e2e-check.sh || rc=$$?; \
|
||||||
|
|||||||
@@ -119,8 +119,8 @@ every message was dropped on the floor — a delivery path that looks wired and
|
|||||||
|
|
||||||
- ponytail ceiling: Objecten emits no notifications, so nothing downstream can react to a
|
- ponytail ceiling: Objecten emits no notifications, so nothing downstream can react to a
|
||||||
register write yet.
|
register write yet.
|
||||||
- Upgrade path: S-19b (#150) needs those notifications to source the projection from
|
- **Lifted by ADR-0029** (S-19b-1, #152): broker, worker, `objecten` kanaal and
|
||||||
Objecten, and turns them on together with the broker, worker, kanaal and abonnement.
|
notifications config now exist, and `NOTIFICATIONS_DISABLED` is `false`.
|
||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
# ADR-0029: Objecten publishes register events to NRC
|
||||||
|
|
||||||
|
- **Status:** Accepted
|
||||||
|
- **Date:** 2026-08-14
|
||||||
|
- **Deciders:** Respellion engineering
|
||||||
|
- **Slice:** S-19b-1 (#152), first of the S-19b (#150) split
|
||||||
|
- **Supersedes in part:** ADR-0028's "Objecten's notifications are off for this slice"
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
ADR-0028 put the authoritative register record in the Objecten API and had the ACL write
|
||||||
|
it on approval. It also switched Objecten's notifications **off** — deliberately, with a
|
||||||
|
stated ceiling: there was no broker, no worker, no `objecten` kanaal and no abonnement, so
|
||||||
|
turning the client side on alone would have produced a delivery path that looks wired and
|
||||||
|
drops every message.
|
||||||
|
|
||||||
|
S-19b-2 (#153) wants the read projection sourced from register writes rather than
|
||||||
|
re-derived from ZGW zaak events. That needs the notifications to actually arrive. This ADR
|
||||||
|
builds the four missing pieces and lifts the ceiling.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**Objecten publishes to the same NRC OpenZaak already publishes to, on the `objecten`
|
||||||
|
kanaal, delivered by its own Celery worker — provisioned declaratively on both sides,
|
||||||
|
exactly as ADR-0007 did for OpenZaak.**
|
||||||
|
|
||||||
|
- **Objecten** (`infra/objecten/setup_configuration/data.yaml`): a `zgw_consumers` service
|
||||||
|
`nrc` (api_type `nrc`) plus a `notifications_config` step naming it, and
|
||||||
|
`NOTIFICATIONS_DISABLED: "false"` in both compose files.
|
||||||
|
- **NRC** (`infra/opennotificaties/setup_configuration/data.yaml`): an `objecten` kanaal
|
||||||
|
alongside `zaken`.
|
||||||
|
- **`objecten-celery`**: a worker container on the Objecten image (`/celery_worker.sh`),
|
||||||
|
mirroring `oz-celery`, with `CELERY_BROKER_URL`/`CELERY_RESULT_BACKEND` on
|
||||||
|
`objecten-redis` db 1 (db 0 is already the cache).
|
||||||
|
|
||||||
|
### One NRC, one credential, one kanaal per publisher
|
||||||
|
|
||||||
|
Objecten reuses the `big-reference-seed` client OpenZaak publishes with. NRC verifies its
|
||||||
|
JWT and authorizes it against OpenZaak's Autorisaties API (ADR-0007), which grants that
|
||||||
|
client `heeft_alle_autorisaties` — so no second credential and no publisher-specific
|
||||||
|
authorization is needed. A second NRC, or a second credential, would buy isolation this
|
||||||
|
reference application has no use for.
|
||||||
|
|
||||||
|
The kanaal name is **not ours to choose**: the Objects API sends
|
||||||
|
`NOTIFICATIONS_KANAAL = "objecten"`. NRC rejects a publish to an unregistered kanaal
|
||||||
|
(`"Kanaal met deze naam bestaat niet"`), which is precisely what the failing check for this
|
||||||
|
slice reported first. Its filter set (`object_type`) matches the kenmerken the Objects API
|
||||||
|
sends, so an abonnement can narrow to one objecttype instead of receiving every write.
|
||||||
|
|
||||||
|
### Writers address Objecten as `objecten.local` — NRC rejects single-label hosts
|
||||||
|
|
||||||
|
NRC types a notification's `hoofdObject` and `resourceUrl` as DRF `URLField`s, so Django's
|
||||||
|
`URLValidator` runs on them — and it refuses a **single-label** host. Objecten fills both
|
||||||
|
from the object `url` that DRF built with `request.build_absolute_uri`, i.e. **the Host the
|
||||||
|
caller used**. Write to `http://objecten:8000` and NRC answers every publish with
|
||||||
|
|
||||||
|
```
|
||||||
|
{"hoofdObject":["Voer een geldige URL in."],"resourceUrl":["Voer een geldige URL in."]}
|
||||||
|
```
|
||||||
|
|
||||||
|
which `objecten-celery` then retries with exponential backoff, forever, in the background —
|
||||||
|
the write itself having returned 201.
|
||||||
|
|
||||||
|
`SITE_DOMAIN` does **not** fix this; it is not what builds those URLs. The fix is on the
|
||||||
|
caller side: the `objecten` service carries an `objecten.local` network alias, and every
|
||||||
|
component whose writes must be notified — the ACL (`Acl__Objecten__BaseUrl`), the gateway
|
||||||
|
integration tests, this slice's verify check — addresses it there. An alias rather than a
|
||||||
|
plain dotted `SITE_DOMAIN` so the host still **resolves in-network**: a subscriber that
|
||||||
|
follows `resourceUrl` reaches the record it points at, which S-19b-2 will do. Readers are
|
||||||
|
unaffected and keep using the plain service name.
|
||||||
|
|
||||||
|
This is the same class of constraint as ADR-0028's "the ACL's Objecttypen base URL must
|
||||||
|
match Objecten's configured `api_root`": these modules put request-derived hosts into data
|
||||||
|
another module then validates or dereferences.
|
||||||
|
|
||||||
|
- ponytail ceiling: nothing *enforces* that a new writer uses the alias — it would get a 201
|
||||||
|
and silently no notification.
|
||||||
|
- Upgrade path: if a second writer ever appears, rename the compose service to `objecten.local`
|
||||||
|
so the plain name stops working, rather than adding a lint.
|
||||||
|
|
||||||
|
### A worker, not a synchronous send
|
||||||
|
|
||||||
|
`notifications_api_common` only schedules the send on transaction commit. Without a worker
|
||||||
|
the task sits in redis forever and every register write is silently undelivered — the exact
|
||||||
|
half-wired state ADR-0028 refused to ship. No `beat` for Objecten: it is a publisher, not a
|
||||||
|
subscriber, and `nrc-beat` already drains NRC's delivery queue.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
`make verify-objecten-notifications` (`infra/run-objecten-notifications-check.sh`, in the
|
||||||
|
CI `verify-stack` job) registers an abonnement on the `objecten` kanaal pointing at a
|
||||||
|
throwaway webhook sink, writes a `RegisterRecord` exactly as the ACL does on approval, and
|
||||||
|
asserts the notification reaches the sink. That is the whole chain in one assertion:
|
||||||
|
Objecten → `objecten-celery` → NRC → `nrc-beat` → the callback. Any missing piece — broker,
|
||||||
|
worker, kanaal, notifications config — shows up as a non-delivery rather than as a green
|
||||||
|
config.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
**Positive**
|
||||||
|
|
||||||
|
- A register write is now observable by anything that subscribes, which is what S-19b-2
|
||||||
|
(#153) needs to make the projection a cache of Objecten rather than a re-derivation of ZGW.
|
||||||
|
- ADR-0028's ceiling is lifted: the delivery path is proven end to end, not merely configured.
|
||||||
|
|
||||||
|
**Negative / costs**
|
||||||
|
|
||||||
|
- One more long-running container (`objecten-celery`) on an already memory-tight CI runner.
|
||||||
|
- A second publisher on the shared `big-reference-seed` credential — a credential rotation
|
||||||
|
now touches two modules.
|
||||||
|
- Objecten now has two in-network names, and which one a caller uses silently decides
|
||||||
|
whether its writes are notified (ceiling above).
|
||||||
|
- ponytail ceiling: notification delivery has no dead-letter or alerting — a failed publish
|
||||||
|
is visible only in the worker log.
|
||||||
|
- Upgrade path: if undelivered register events start mattering, subscribe an audit sink or
|
||||||
|
read NRC's own delivery admin rather than building a retry layer here.
|
||||||
|
|
||||||
|
## Coupling rules touched (CLAUDE.md §8)
|
||||||
|
|
||||||
|
None bent. This is infrastructure between two upstream modules, over their documented
|
||||||
|
APIs; no service reaches another's database. §8.6 (idempotency at every event boundary)
|
||||||
|
applies to whatever consumes the new kanaal — S-19b-2's problem, not this slice's.
|
||||||
@@ -341,7 +341,8 @@ services:
|
|||||||
# Objecten holds the register, OpenZaak holds the process (S-19a, ADR-0028). Both APIs take a
|
# Objecten holds the register, OpenZaak holds the process (S-19a, ADR-0028). Both APIs take a
|
||||||
# static token, not a ZGW JWT. The objecttype URL is assigned at seed time, so the ACL resolves
|
# static token, not a ZGW JWT. The objecttype URL is assigned at seed time, so the ACL resolves
|
||||||
# it by name — lazily, on the first approval, so no depends_on is needed here.
|
# it by name — lazily, on the first approval, so no depends_on is needed here.
|
||||||
Acl__Objecten__BaseUrl: http://objecten:8000/
|
# Dotted host on purpose — see the `objecten.local` alias below (ADR-0029).
|
||||||
|
Acl__Objecten__BaseUrl: http://objecten.local:8000/
|
||||||
Acl__Objecten__Token: ${OBJECTEN_TOKEN:-1234567890abcdef1234567890abcdef12345678}
|
Acl__Objecten__Token: ${OBJECTEN_TOKEN:-1234567890abcdef1234567890abcdef12345678}
|
||||||
Acl__Objecten__ObjecttypenBaseUrl: http://objecttypen:8000/
|
Acl__Objecten__ObjecttypenBaseUrl: http://objecttypen:8000/
|
||||||
Acl__Objecten__ObjecttypenToken: ${OBJECTTYPEN_TOKEN:-0123456789abcdef0123456789abcdef01234567}
|
Acl__Objecten__ObjecttypenToken: ${OBJECTTYPEN_TOKEN:-0123456789abcdef0123456789abcdef01234567}
|
||||||
@@ -691,12 +692,14 @@ services:
|
|||||||
CACHE_AXES: objecten-redis:6379/0
|
CACHE_AXES: objecten-redis:6379/0
|
||||||
DISABLE_2FA: "true"
|
DISABLE_2FA: "true"
|
||||||
OTEL_SDK_DISABLED: "true"
|
OTEL_SDK_DISABLED: "true"
|
||||||
# S-19a: Objecten refuses every write while its Notificaties config is absent
|
CELERY_BROKER_URL: redis://objecten-redis:6379/1
|
||||||
# (notifications_api_common raises rather than skipping, so POST /objects 500s). Objecten →
|
CELERY_RESULT_BACKEND: redis://objecten-redis:6379/1
|
||||||
# NRC is not wired yet — there is no broker, worker, kanaal or abonnement for it — so turn
|
# Publish register-record events to NRC on the `objecten` kanaal (S-19b-1, ADR-0029). The NRC
|
||||||
# notifications off rather than fake a delivery path that silently drops every message.
|
# service + notifications_config are provisioned by setup_configuration
|
||||||
# S-19b (#150) sources the projection from Objecten and turns this back on for real.
|
# (infra/objecten/setup_configuration/data.yaml), and objecten-celery below actually sends
|
||||||
NOTIFICATIONS_DISABLED: "true"
|
# them — notifications_api_common only queues the task. See ADR-0028 for why S-19a left this
|
||||||
|
# off until all four pieces existed.
|
||||||
|
NOTIFICATIONS_DISABLED: "false"
|
||||||
RUN_SETUP_CONFIG: "true"
|
RUN_SETUP_CONFIG: "true"
|
||||||
command: /setup_configuration.sh
|
command: /setup_configuration.sh
|
||||||
volumes:
|
volumes:
|
||||||
@@ -721,6 +724,28 @@ services:
|
|||||||
start_period: 30s
|
start_period: 30s
|
||||||
ports:
|
ports:
|
||||||
- "8021:8000"
|
- "8021:8000"
|
||||||
|
depends_on:
|
||||||
|
objecten-init:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
networks:
|
||||||
|
cg:
|
||||||
|
# Objecten reflects the *request* Host into the `url` it returns, and
|
||||||
|
# notifications_api_common publishes that url as the notification's hoofdObject /
|
||||||
|
# resourceUrl — which NRC types as a URLField, and Django's URLValidator rejects a
|
||||||
|
# single-label host ("Voer een geldige URL in."). So every caller whose writes must be
|
||||||
|
# notified addresses Objecten by this dotted alias instead of `objecten` (ADR-0029).
|
||||||
|
# Reads are unaffected and still use the plain service name.
|
||||||
|
aliases:
|
||||||
|
- objecten.local
|
||||||
|
|
||||||
|
# The celery worker that actually delivers Objecten's notifications to NRC (S-19b-1, ADR-0029).
|
||||||
|
# notifications_api_common only schedules the send on transaction commit; without a worker the
|
||||||
|
# task sits in redis forever and every register write is silently undelivered. Mirrors oz-celery.
|
||||||
|
# No beat: Objecten is a publisher, not a subscriber — nrc-beat drains the delivery queue.
|
||||||
|
objecten-celery:
|
||||||
|
image: docker.io/maykinmedia/objects-api:${OBJECTS_TAG:-3.4.0}
|
||||||
|
environment: *objecten-env-local
|
||||||
|
command: /celery_worker.sh
|
||||||
depends_on:
|
depends_on:
|
||||||
objecten-init:
|
objecten-init:
|
||||||
condition: service_completed_successfully
|
condition: service_completed_successfully
|
||||||
|
|||||||
@@ -326,7 +326,8 @@ services:
|
|||||||
# Objecten holds the register, OpenZaak holds the process (S-19a, ADR-0028). Both APIs take a
|
# Objecten holds the register, OpenZaak holds the process (S-19a, ADR-0028). Both APIs take a
|
||||||
# static token, not a ZGW JWT. The objecttype URL is assigned at seed time, so the ACL resolves
|
# static token, not a ZGW JWT. The objecttype URL is assigned at seed time, so the ACL resolves
|
||||||
# it by name — lazily, on the first approval, so no depends_on is needed here.
|
# it by name — lazily, on the first approval, so no depends_on is needed here.
|
||||||
Acl__Objecten__BaseUrl: http://objecten:8000/
|
# Dotted host on purpose — see the `objecten.local` alias below (ADR-0029).
|
||||||
|
Acl__Objecten__BaseUrl: http://objecten.local:8000/
|
||||||
Acl__Objecten__Token: ${OBJECTEN_TOKEN:-1234567890abcdef1234567890abcdef12345678}
|
Acl__Objecten__Token: ${OBJECTEN_TOKEN:-1234567890abcdef1234567890abcdef12345678}
|
||||||
Acl__Objecten__ObjecttypenBaseUrl: http://objecttypen:8000/
|
Acl__Objecten__ObjecttypenBaseUrl: http://objecttypen:8000/
|
||||||
Acl__Objecten__ObjecttypenToken: ${OBJECTTYPEN_TOKEN:-0123456789abcdef0123456789abcdef01234567}
|
Acl__Objecten__ObjecttypenToken: ${OBJECTTYPEN_TOKEN:-0123456789abcdef0123456789abcdef01234567}
|
||||||
@@ -717,12 +718,14 @@ services:
|
|||||||
CACHE_AXES: objecten-redis:6379/0
|
CACHE_AXES: objecten-redis:6379/0
|
||||||
DISABLE_2FA: "true"
|
DISABLE_2FA: "true"
|
||||||
OTEL_SDK_DISABLED: "true"
|
OTEL_SDK_DISABLED: "true"
|
||||||
# S-19a: Objecten refuses every write while its Notificaties config is absent
|
CELERY_BROKER_URL: redis://objecten-redis:6379/1
|
||||||
# (notifications_api_common raises rather than skipping, so POST /objects 500s). Objecten →
|
CELERY_RESULT_BACKEND: redis://objecten-redis:6379/1
|
||||||
# NRC is not wired yet — there is no broker, worker, kanaal or abonnement for it — so turn
|
# Publish register-record events to NRC on the `objecten` kanaal (S-19b-1, ADR-0029). The NRC
|
||||||
# notifications off rather than fake a delivery path that silently drops every message.
|
# service + notifications_config are provisioned by setup_configuration
|
||||||
# S-19b (#150) sources the projection from Objecten and turns this back on for real.
|
# (infra/objecten/setup_configuration/data.yaml), and objecten-celery below actually sends
|
||||||
NOTIFICATIONS_DISABLED: "true"
|
# them — notifications_api_common only queues the task. See ADR-0028 for why S-19a left this
|
||||||
|
# off until all four pieces existed.
|
||||||
|
NOTIFICATIONS_DISABLED: "false"
|
||||||
RUN_SETUP_CONFIG: "true"
|
RUN_SETUP_CONFIG: "true"
|
||||||
command: /setup_configuration.sh
|
command: /setup_configuration.sh
|
||||||
# data.yaml is streamed into this external volume by infra/seed-config.sh before start.
|
# data.yaml is streamed into this external volume by infra/seed-config.sh before start.
|
||||||
@@ -750,6 +753,28 @@ services:
|
|||||||
start_period: 30s
|
start_period: 30s
|
||||||
ports:
|
ports:
|
||||||
- "8021:8000"
|
- "8021:8000"
|
||||||
|
depends_on:
|
||||||
|
objecten-init:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
networks:
|
||||||
|
cg:
|
||||||
|
# Objecten reflects the *request* Host into the `url` it returns, and
|
||||||
|
# notifications_api_common publishes that url as the notification's hoofdObject /
|
||||||
|
# resourceUrl — which NRC types as a URLField, and Django's URLValidator rejects a
|
||||||
|
# single-label host ("Voer een geldige URL in."). So every caller whose writes must be
|
||||||
|
# notified addresses Objecten by this dotted alias instead of `objecten` (ADR-0029).
|
||||||
|
# Reads are unaffected and still use the plain service name.
|
||||||
|
aliases:
|
||||||
|
- objecten.local
|
||||||
|
|
||||||
|
# The celery worker that actually delivers Objecten's notifications to NRC (S-19b-1, ADR-0029).
|
||||||
|
# notifications_api_common only schedules the send on transaction commit; without a worker the
|
||||||
|
# task sits in redis forever and every register write is silently undelivered. Mirrors oz-celery.
|
||||||
|
# No beat: Objecten is a publisher, not a subscriber — nrc-beat drains the delivery queue.
|
||||||
|
objecten-celery:
|
||||||
|
image: docker.io/maykinmedia/objects-api:${OBJECTS_TAG:-3.4.0}
|
||||||
|
environment: *objecten-env
|
||||||
|
command: /celery_worker.sh
|
||||||
depends_on:
|
depends_on:
|
||||||
objecten-init:
|
objecten-init:
|
||||||
condition: service_completed_successfully
|
condition: service_completed_successfully
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""S-19b-1 (#152): driver for the Objecten → NRC notification check.
|
||||||
|
|
||||||
|
Registers an abonnement on the `objecten` kanaal pointing at the webhook sink, then writes a
|
||||||
|
RegisterRecord object exactly as the ACL's ObjectenGateway does (S-19a). The caller
|
||||||
|
(run-objecten-notifications-check.sh) watches the sink for the delivery — this only sets it up,
|
||||||
|
and prints `OBJECT_URL <url>` for the caller to grep on.
|
||||||
|
|
||||||
|
Delivery exercises the whole chain: Objecten → its celery worker → NRC → nrc-beat → the callback.
|
||||||
|
Anything missing (broker, worker, kanaal, notifications config) shows up as a non-delivery.
|
||||||
|
|
||||||
|
Stdlib only so it runs in a bare python:3-slim container on the compose network.
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
OBJECTEN = os.environ["OBJECTEN"] # http://objecten:8000
|
||||||
|
OBJECTEN_TOKEN = os.environ["OBJECTEN_TOKEN"]
|
||||||
|
OBJECTTYPEN = os.environ["OBJECTTYPEN"] # http://objecttypen:8000
|
||||||
|
OBJECTTYPEN_TOKEN = os.environ["OBJECTTYPEN_TOKEN"]
|
||||||
|
NRC_BASE = os.environ["NRC_BASE"] # http://<nrc-ip>:8000
|
||||||
|
SINK_CALLBACK = os.environ["SINK_CALLBACK"] # http://<sink-ip>:9000/
|
||||||
|
SINK_AUTH = os.environ["SINK_AUTH"]
|
||||||
|
CLIENT_ID = os.environ.get("NRC_CLIENT_ID", "big-reference-seed")
|
||||||
|
SECRET = os.environ.get("NRC_SECRET", "insecure-dev-secret-change-me")
|
||||||
|
KANAAL = "objecten"
|
||||||
|
|
||||||
|
|
||||||
|
def mint():
|
||||||
|
"""The HS256 JWT NRC expects (same shape as infra/local/register-abonnement.py)."""
|
||||||
|
def seg(d):
|
||||||
|
return base64.urlsafe_b64encode(json.dumps(d).encode()).rstrip(b"=")
|
||||||
|
|
||||||
|
payload = seg({
|
||||||
|
"iss": CLIENT_ID, "iat": int(time.time()), "client_id": CLIENT_ID,
|
||||||
|
"user_id": CLIENT_ID, "user_representation": CLIENT_ID,
|
||||||
|
})
|
||||||
|
signing_input = seg({"typ": "JWT", "alg": "HS256"}) + b"." + payload
|
||||||
|
signature = base64.urlsafe_b64encode(
|
||||||
|
hmac.new(SECRET.encode(), signing_input, hashlib.sha256).digest()).rstrip(b"=")
|
||||||
|
return (signing_input + b"." + signature).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def nrc(method, url, body=None):
|
||||||
|
"""Call NRC. `url` may be a path or an absolute URL (the list returns absolute ones)."""
|
||||||
|
data = json.dumps(body).encode() if body is not None else None
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url if url.startswith("http") else f"{NRC_BASE}{url}", data=data, method=method,
|
||||||
|
headers={"Authorization": f"Bearer {mint()}", "Content-Type": "application/json"})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=15) as r:
|
||||||
|
return json.load(r) if r.length != 0 else {}
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
# The body carries the reason (e.g. an unregistered kanaal); the status alone does not.
|
||||||
|
raise SystemExit(f"FAIL — NRC {method} {url} → {e.code}: {e.read().decode(errors='replace')[:400]}")
|
||||||
|
|
||||||
|
|
||||||
|
def token_api(base, token, method, path, body=None, crs=False):
|
||||||
|
data = json.dumps(body).encode() if body is not None else None
|
||||||
|
headers = {"Authorization": f"Token {token}"}
|
||||||
|
if body is not None:
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
if crs:
|
||||||
|
headers["Accept-Crs"] = "EPSG:4326"
|
||||||
|
if body is not None:
|
||||||
|
headers["Content-Crs"] = "EPSG:4326"
|
||||||
|
req = urllib.request.Request(f"{base}{path}", data=data, method=method, headers=headers)
|
||||||
|
with urllib.request.urlopen(req, timeout=15) as r:
|
||||||
|
return json.load(r) if r.length != 0 else {}
|
||||||
|
|
||||||
|
|
||||||
|
def subscribe():
|
||||||
|
"""Register an abonnement on the objecten kanaal, replacing a stale one for the same callback."""
|
||||||
|
# NRC returns a bare list here, not a paginated envelope.
|
||||||
|
for existing in nrc("GET", "/api/v1/abonnement") or []:
|
||||||
|
if existing.get("callbackUrl") == SINK_CALLBACK:
|
||||||
|
nrc("DELETE", existing["url"])
|
||||||
|
nrc("POST", "/api/v1/abonnement", {
|
||||||
|
"callbackUrl": SINK_CALLBACK,
|
||||||
|
"auth": SINK_AUTH,
|
||||||
|
"kanalen": [{"naam": KANAAL, "filters": {}}],
|
||||||
|
})
|
||||||
|
print(f">> abonnement on '{KANAAL}' -> {SINK_CALLBACK}")
|
||||||
|
|
||||||
|
|
||||||
|
def objecttype_url():
|
||||||
|
results = token_api(OBJECTTYPEN, OBJECTTYPEN_TOKEN, "GET", "/api/v2/objecttypes").get("results", [])
|
||||||
|
match = next((o for o in results if o.get("name") == "RegisterRecord"), None)
|
||||||
|
if not match:
|
||||||
|
print("FAIL — no RegisterRecord objecttype in Objecttypen", file=sys.stderr)
|
||||||
|
raise SystemExit(1)
|
||||||
|
return match["url"]
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
subscribe()
|
||||||
|
reference = f"NOTIF-{int(time.time())}"
|
||||||
|
created = token_api(OBJECTEN, OBJECTEN_TOKEN, "POST", "/api/v2/objects", {
|
||||||
|
"type": objecttype_url(),
|
||||||
|
"record": {
|
||||||
|
"typeVersion": 1,
|
||||||
|
"data": {"id": f"zaak-{reference}", "status": "INGESCHREVEN", "reference": reference},
|
||||||
|
"startAt": time.strftime("%Y-%m-%d"),
|
||||||
|
},
|
||||||
|
}, crs=True)
|
||||||
|
print(f">> wrote RegisterRecord {created['url']}")
|
||||||
|
# An NRC notification carries no record data — only hoofdObject/resourceUrl — so the object
|
||||||
|
# URL, not the reference in its data, is what the caller can correlate the delivery on.
|
||||||
|
print(f"OBJECT_URL {created['url']}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -18,6 +18,16 @@ zgw_consumers:
|
|||||||
auth_type: api_key
|
auth_type: api_key
|
||||||
header_key: Authorization
|
header_key: Authorization
|
||||||
header_value: Token 0123456789abcdef0123456789abcdef01234567
|
header_value: Token 0123456789abcdef0123456789abcdef01234567
|
||||||
|
# (1b) The NRC Objecten publishes register-record events to (S-19b-1, ADR-0029). Same shape and
|
||||||
|
# same big-reference-seed credential OpenZaak publishes with — NRC verifies the JWT and
|
||||||
|
# authorizes it via OpenZaak's AC, which grants that client heeft_alle_autorisaties.
|
||||||
|
- identifier: nrc
|
||||||
|
label: Open Notificaties
|
||||||
|
api_type: nrc
|
||||||
|
api_root: http://nrc-web:8000/api/v1/
|
||||||
|
auth_type: zgw
|
||||||
|
client_id: big-reference-seed
|
||||||
|
secret: insecure-dev-secret-change-me
|
||||||
|
|
||||||
# (2) Permit the RegisterRecord objecttype (S-19a). Objecten refuses to store an object whose
|
# (2) Permit the RegisterRecord objecttype (S-19a). Objecten refuses to store an object whose
|
||||||
# objecttype it has not been configured with ("ObjectType with url=… is not configured"), and it
|
# objecttype it has not been configured with ("ObjectType with url=… is not configured"), and it
|
||||||
@@ -40,3 +50,10 @@ tokenauth:
|
|||||||
email: admin@localhost
|
email: admin@localhost
|
||||||
organization: Respellion
|
organization: Respellion
|
||||||
is_superuser: true
|
is_superuser: true
|
||||||
|
|
||||||
|
# (4) Point Objecten's notifications at that NRC service (S-19b-1, ADR-0029). Requires
|
||||||
|
# NOTIFICATIONS_DISABLED=false plus a celery broker + worker — without the worker the message is
|
||||||
|
# queued and never sent, which is exactly the half-wired state S-19a refused to ship (ADR-0028).
|
||||||
|
notifications_config_enable: true
|
||||||
|
notifications_config:
|
||||||
|
notifications_api_service_identifier: nrc
|
||||||
|
|||||||
@@ -29,7 +29,9 @@ autorisaties_api_config_enable: true
|
|||||||
autorisaties_api:
|
autorisaties_api:
|
||||||
authorizations_api_service_identifier: openzaak-ac
|
authorizations_api_service_identifier: openzaak-ac
|
||||||
|
|
||||||
# 4. The kanaal OpenZaak publishes zaak events on.
|
# 4. The kanalen publishers announce on: `zaken` (OpenZaak) and `objecten` (Objecten, S-19b-1).
|
||||||
|
# Both authenticate with the big-reference-seed credential above, which OpenZaak's AC grants
|
||||||
|
# heeft_alle_autorisaties — so no separate publisher authorization is needed for Objecten.
|
||||||
notifications_kanalen_config_enable: true
|
notifications_kanalen_config_enable: true
|
||||||
notifications_kanalen_config:
|
notifications_kanalen_config:
|
||||||
items:
|
items:
|
||||||
@@ -39,3 +41,11 @@ notifications_kanalen_config:
|
|||||||
- bronorganisatie
|
- bronorganisatie
|
||||||
- zaaktype
|
- zaaktype
|
||||||
- vertrouwelijkheidaanduiding
|
- vertrouwelijkheidaanduiding
|
||||||
|
# 5. The kanaal Objecten publishes register-record events on (S-19b-1, ADR-0029). Its name is
|
||||||
|
# fixed by the Objects API itself (NOTIFICATIONS_KANAAL = "objecten"), not chosen here. The
|
||||||
|
# filter set matches what the Objects API sends as kenmerken, so an abonnement can narrow by
|
||||||
|
# objecttype rather than receiving every object write in the register.
|
||||||
|
- naam: objecten
|
||||||
|
documentatie_link: https://objects-and-objecttypes-api.readthedocs.io/
|
||||||
|
filters:
|
||||||
|
- object_type
|
||||||
|
|||||||
Executable
+83
@@ -0,0 +1,83 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# S-19b-1 (#152): verify the Objecten → NRC notification path against an ALREADY-RUNNING full
|
||||||
|
# stack. Registers an abonnement on the `objecten` kanaal pointing at a throwaway webhook sink,
|
||||||
|
# writes a RegisterRecord object (exactly as the ACL does on approval, S-19a), and asserts the sink
|
||||||
|
# receives the notification.
|
||||||
|
#
|
||||||
|
# This is the whole publish chain in one assertion: Objecten → its celery worker → NRC → nrc-beat →
|
||||||
|
# the subscriber callback. S-19a deliberately left it disconnected (ADR-0028); this proves it is
|
||||||
|
# connected for real, rather than merely configured.
|
||||||
|
#
|
||||||
|
# All in-network, reaching services by container IP (a single-label host isn't URL-valid for NRC's
|
||||||
|
# callbackUrl validator; the runner can't reach published ports — gitea-actions-gotchas.md §5/§6).
|
||||||
|
# EXCEPT Objecttypen, which must be reached by SERVICE NAME: it echoes the request Host into the
|
||||||
|
# objecttype `url` and Objecten only accepts the one matching its configured api_root (ADR-0028);
|
||||||
|
# and Objecten, reached by its `objecten.local` alias because it reflects the request Host into the
|
||||||
|
# notification's hoofdObject/resourceUrl, which NRC validates as a URL (ADR-0029).
|
||||||
|
#
|
||||||
|
# Does NOT manage the stack lifecycle, but cleans up the sink/driver it creates.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
SINK_AUTH="Bearer objecten-notification-sink-token"
|
||||||
|
|
||||||
|
cleanup() { docker rm -f rr-osink rr-overify >/dev/null 2>&1 || true; }
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
ip() { docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$1"; }
|
||||||
|
|
||||||
|
# Anchored on the compose replica suffix so they don't also match objecten-db / objecten-redis.
|
||||||
|
obj="$(docker ps -q --filter 'name=objecten[-_][0-9]+$' | head -1)"
|
||||||
|
nrc="$(docker ps -q --filter 'name=nrc-web' | head -1)"
|
||||||
|
[ -n "$obj" ] || { echo "ERROR: no running objecten container — bring the stack up first" >&2; exit 1; }
|
||||||
|
[ -n "$nrc" ] || { echo "ERROR: no running nrc-web container — bring the stack up first" >&2; exit 1; }
|
||||||
|
net="$(docker inspect -f '{{range $k,$_ := .NetworkSettings.Networks}}{{$k}}{{"\n"}}{{end}}' "$obj" | head -1)"
|
||||||
|
nrc_ip="$(ip "$nrc")"
|
||||||
|
echo ">> network=$net nrc=$nrc_ip"
|
||||||
|
|
||||||
|
echo ">> starting the webhook sink"
|
||||||
|
docker rm -f rr-osink >/dev/null 2>&1 || true
|
||||||
|
sink="$(docker create --network "$net" --name rr-osink -e "EXPECTED_AUTH=$SINK_AUTH" \
|
||||||
|
python:3-slim python /sink.py)"
|
||||||
|
docker cp "$here/notification-sink.py" "$sink:/sink.py" >/dev/null
|
||||||
|
docker start "$sink" >/dev/null
|
||||||
|
sleep 1
|
||||||
|
sink_ip="$(ip rr-osink)"
|
||||||
|
echo ">> sink at $sink_ip:9000"
|
||||||
|
|
||||||
|
echo ">> registering the abonnement + writing a RegisterRecord"
|
||||||
|
docker rm -f rr-overify >/dev/null 2>&1 || true
|
||||||
|
drv="$(docker create --network "$net" --name rr-overify \
|
||||||
|
-e "OBJECTEN=http://objecten.local:8000" \
|
||||||
|
-e "OBJECTEN_TOKEN=${OBJECTEN_TOKEN:-1234567890abcdef1234567890abcdef12345678}" \
|
||||||
|
-e "OBJECTTYPEN=http://objecttypen:8000" \
|
||||||
|
-e "OBJECTTYPEN_TOKEN=${OBJECTTYPEN_TOKEN:-0123456789abcdef0123456789abcdef01234567}" \
|
||||||
|
-e "NRC_BASE=http://$nrc_ip:8000" \
|
||||||
|
-e "SINK_CALLBACK=http://$sink_ip:9000/" -e "SINK_AUTH=$SINK_AUTH" \
|
||||||
|
python:3-slim python /driver.py)"
|
||||||
|
docker cp "$here/objecten-notifications-check.py" "$drv:/driver.py" >/dev/null
|
||||||
|
docker start -a "$drv"
|
||||||
|
object_url="$(docker logs rr-overify 2>/dev/null | sed -n 's/^OBJECT_URL //p' | head -1)"
|
||||||
|
docker rm -f rr-overify >/dev/null
|
||||||
|
[ -n "$object_url" ] || { echo "FAIL — the driver did not write a RegisterRecord" >&2; exit 1; }
|
||||||
|
echo ">> wrote $object_url"
|
||||||
|
|
||||||
|
# Correlate on the object URL: a notification carries hoofdObject/resourceUrl, never the record
|
||||||
|
# data, so the reference inside the record is not in the delivered message.
|
||||||
|
echo ">> waiting for the notification to reach the sink"
|
||||||
|
for _ in $(seq 1 "${NOTIFICATION_TRIES:-40}"); do
|
||||||
|
if docker logs rr-osink 2>&1 | grep -qF "$object_url"; then
|
||||||
|
echo "OK — Objecten published to NRC and the abonnement delivered it:"
|
||||||
|
docker logs rr-osink 2>&1 | grep -F "$object_url" | tail -1 | cut -c1-500
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "FAIL — no 'objecten' notification for $object_url reached the sink." >&2
|
||||||
|
echo " Objecten accepted the write, so the gap is downstream: the celery broker/worker," >&2
|
||||||
|
echo " the kanaal registration, or Objecten's notifications_config." >&2
|
||||||
|
echo "--- sink log ---" >&2; docker logs rr-osink 2>&1 | tail -8 >&2
|
||||||
|
echo "--- objecten log ---" >&2; docker logs "$obj" 2>&1 | tail -15 >&2
|
||||||
|
exit 1
|
||||||
@@ -20,7 +20,7 @@ public sealed class ObjectenGatewayIntegrationTests
|
|||||||
new HttpClient(),
|
new HttpClient(),
|
||||||
new ObjectenOptions
|
new ObjectenOptions
|
||||||
{
|
{
|
||||||
BaseUrl = new(Env("OBJECTEN_BASE", "http://objecten:8000")),
|
BaseUrl = new(Env("OBJECTEN_BASE", "http://objecten.local:8000")),
|
||||||
Token = Env("OBJECTEN_TOKEN", "1234567890abcdef1234567890abcdef12345678"),
|
Token = Env("OBJECTEN_TOKEN", "1234567890abcdef1234567890abcdef12345678"),
|
||||||
ObjecttypenBaseUrl = new(Env("OBJECTTYPEN_BASE", "http://objecttypen:8000")),
|
ObjecttypenBaseUrl = new(Env("OBJECTTYPEN_BASE", "http://objecttypen:8000")),
|
||||||
ObjecttypenToken = Env("OBJECTTYPEN_TOKEN", "0123456789abcdef0123456789abcdef01234567"),
|
ObjecttypenToken = Env("OBJECTTYPEN_TOKEN", "0123456789abcdef0123456789abcdef01234567"),
|
||||||
@@ -65,7 +65,7 @@ public sealed class ObjectenGatewayIntegrationTests
|
|||||||
{
|
{
|
||||||
using var http = new HttpClient();
|
using var http = new HttpClient();
|
||||||
var objecttype = await ResolveObjecttypeUrlAsync(http);
|
var objecttype = await ResolveObjecttypeUrlAsync(http);
|
||||||
var query = new Uri(new Uri(Env("OBJECTEN_BASE", "http://objecten:8000")),
|
var query = new Uri(new Uri(Env("OBJECTEN_BASE", "http://objecten.local:8000")),
|
||||||
"/api/v2/objects?type=" + Uri.EscapeDataString(objecttype) +
|
"/api/v2/objects?type=" + Uri.EscapeDataString(objecttype) +
|
||||||
"&data_attrs=id__exact__" + Uri.EscapeDataString(id));
|
"&data_attrs=id__exact__" + Uri.EscapeDataString(id));
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user