From fe146a6dbf340a6c72dcf012c59977b592802ff2 Mon Sep 17 00:00:00 2001 From: Alexander Kiel Date: Fri, 13 Sep 2024 19:17:54 +0200 Subject: [PATCH] Implement the delete-history Command Closes: #1382 --- .github/scripts/admin-api/async-job.sh | 4 +- .github/scripts/authenticated-request.sh | 8 +- .github/scripts/batch-metadata.sh | 16 +- .github/scripts/batch.sh | 16 +- .github/scripts/cancel-async.sh | 4 +- .../chaining-without-referential-integrity.sh | 4 +- .github/scripts/check-date-search.sh | 2 +- .../check-referential-integrity-enforced.sh | 2 +- ...heck-referential-integrity-not-enforced.sh | 2 +- .github/scripts/conditional-create.sh | 8 +- .github/scripts/delete-history.sh | 95 ++++++ .github/scripts/delete.sh | 20 +- .../scripts/download-resources-query-sort.sh | 20 +- .github/scripts/download-resources-query.sh | 12 +- .github/scripts/evaluate-measure-as-batch.sh | 4 +- .github/scripts/evaluate-measure-async.sh | 4 +- .../evaluate-measure-blazectl-stratifier.sh | 8 +- .github/scripts/evaluate-measure-blazectl.sh | 4 +- .../evaluate-measure-subject-list-as-batch.sh | 8 +- .../scripts/evaluate-measure-subject-list.sh | 8 +- .github/scripts/evaluate-measure-timeout.sh | 4 +- .github/scripts/evaluate-measure.sh | 4 +- .../scripts/evaluate-patient-q1-measure.sh | 8 +- .github/scripts/forwarded-header.sh | 4 +- .../include-without-referential-integrity.sh | 4 +- .github/scripts/not-acceptable.sh | 2 +- .github/scripts/revinclude.sh | 12 +- .github/scripts/search-compartment.sh | 4 +- .../scripts/search-patient-last-updated.sh | 4 +- .github/scripts/util.sh | 25 +- .github/scripts/x-forwarded-headers.sh | 4 +- .github/workflows/build.yml | 9 + docs/api.md | 165 +++++++++-- .../db-tx-log/src/blaze/db/tx_log/spec.clj | 7 +- .../test/blaze/db/tx_log/spec_test.clj | 32 +- modules/db/src/blaze/db/impl/batch_db.clj | 14 +- .../blaze/db/impl/index/resource_as_of.clj | 35 +-- .../blaze/db/impl/index/resource_handle.clj | 16 +- .../db/src/blaze/db/impl/index/rts_as_of.clj | 33 ++- .../src/blaze/db/impl/index/system_as_of.clj | 17 +- .../db/src/blaze/db/impl/index/type_as_of.clj | 15 +- modules/db/src/blaze/db/node/transaction.clj | 7 + .../src/blaze/db/node/tx_indexer/verify.clj | 40 +++ modules/db/src/blaze/db/node/validation.clj | 2 + modules/db/src/blaze/db/spec.clj | 5 + modules/db/test/blaze/db/api_test.clj | 279 ++++++++++++++++-- .../db/impl/index/resource_as_of_spec.clj | 9 +- .../db/impl/index/resource_handle_spec.clj | 4 + .../db/impl/index/resource_handle_test.clj | 10 +- .../blaze/db/impl/index/rts_as_of_spec.clj | 3 +- .../db/impl/index/rts_as_of_test_util.clj | 9 +- .../blaze/db/impl/index/system_as_of_spec.clj | 1 + .../blaze/db/impl/index/type_as_of_spec.clj | 1 + .../blaze/db/node/tx_indexer/verify_test.clj | 124 +++++++- .../test/blaze/http_client_test.clj | 2 +- modules/interaction/.clj-kondo/config.edn | 3 +- .../src/blaze/interaction/delete_history.clj | 24 ++ .../src/blaze/interaction/read.clj | 28 +- .../src/blaze/interaction/util.clj | 24 +- .../src/blaze/interaction/vread.clj | 38 +++ .../conditional_delete_type_test.clj | 2 +- .../blaze/interaction/delete_history_test.clj | 79 +++++ .../test/blaze/interaction/delete_test.clj | 8 +- .../interaction/history/instance_test.clj | 3 + .../test/blaze/interaction/read_test.clj | 50 +--- .../blaze/interaction/search_type_test.clj | 6 +- .../test/blaze/interaction/vread_test.clj | 194 ++++++++++++ modules/jepsen/Makefile | 14 +- modules/jepsen/deps.edn | 5 +- .../src/blaze/jepsen/resource_history.clj | 128 ++++++++ .../rest-api/src/blaze/rest_api/routes.clj | 12 +- modules/rest-api/src/blaze/rest_api/spec.clj | 1 + .../test/blaze/rest_api/routes_test.clj | 11 +- .../rest-util/src/blaze/handler/fhir/util.clj | 75 +++-- .../src/blaze/handler/fhir/util_spec.clj | 9 + modules/rest-util/src/blaze/handler/util.clj | 4 +- .../rest-util/src/blaze/handler/util_spec.clj | 4 + .../src/blaze/middleware/fhir/db.clj | 17 -- .../src/blaze/middleware/fhir/db_spec.clj | 3 - .../test/blaze/handler/fhir/util_test.clj | 60 +++- .../test/blaze/middleware/fhir/db_test.clj | 52 ---- modules/spec/src/blaze/spec.clj | 6 + resources/blaze.edn | 9 +- test/blaze/system_test.clj | 78 ++++- 84 files changed, 1691 insertions(+), 419 deletions(-) create mode 100755 .github/scripts/delete-history.sh create mode 100644 modules/interaction/src/blaze/interaction/delete_history.clj create mode 100644 modules/interaction/src/blaze/interaction/vread.clj create mode 100644 modules/interaction/test/blaze/interaction/delete_history_test.clj create mode 100644 modules/interaction/test/blaze/interaction/vread_test.clj create mode 100644 modules/jepsen/src/blaze/jepsen/resource_history.clj diff --git a/.github/scripts/admin-api/async-job.sh b/.github/scripts/admin-api/async-job.sh index be3889510..458141e99 100755 --- a/.github/scripts/admin-api/async-job.sh +++ b/.github/scripts/admin-api/async-job.sh @@ -26,9 +26,9 @@ AUTHORED_ON_ISO=$(echo "$JOB" | jq -r '.authoredOn') AUTHORED_ON_EPOCH_SECONDS=$($DATE_CMD -d "$AUTHORED_ON_ISO" +%s) NOW_EPOCH_SECONDS=$($DATE_CMD +%s) if ((NOW_EPOCH_SECONDS - AUTHORED_ON_EPOCH_SECONDS < 10)); then - echo "OK πŸ‘: the authoredOn dateTime is set and current" + echo "βœ… the authoredOn dateTime is set and current" else - echo "Fail 😞: the authoredOn dateTime is $AUTHORED_ON_ISO, but should be a current dateTime" + echo "πŸ†˜ the authoredOn dateTime is $AUTHORED_ON_ISO, but should be a current dateTime" exit 1 fi diff --git a/.github/scripts/authenticated-request.sh b/.github/scripts/authenticated-request.sh index 5739cec4b..81c947a5a 100755 --- a/.github/scripts/authenticated-request.sh +++ b/.github/scripts/authenticated-request.sh @@ -10,15 +10,15 @@ fi BASE="http://localhost:8080/fhir" if [ "200" = "$(curl -s --oauth2-bearer "$ACCESS_TOKEN" -o /dev/null -w '%{response_code}' "$BASE")" ]; then - echo "OK πŸ‘: successful authenticated system search request" + echo "βœ… successful authenticated system search request" else - echo "Fail 😞: failed authenticated system search request" + echo "πŸ†˜ failed authenticated system search request" exit 1 fi if [ "200" = "$(curl -s --oauth2-bearer "$ACCESS_TOKEN" -H "Content-Type: application/fhir+json" -d @.github/openid-auth-test/batch-bundle.json "$BASE" | jq -r '.entry[].response.status')" ]; then - echo "OK πŸ‘: successful authenticated batch request" + echo "βœ… successful authenticated batch request" else - echo "Fail 😞: failed authenticated batch request" + echo "πŸ†˜ failed authenticated batch request" exit 1 fi diff --git a/.github/scripts/batch-metadata.sh b/.github/scripts/batch-metadata.sh index af90b5fcb..77cd37c9e 100755 --- a/.github/scripts/batch-metadata.sh +++ b/.github/scripts/batch-metadata.sh @@ -26,32 +26,32 @@ RESULT=$(curl -sH "Content-Type: application/fhir+json" -d "$(bundle)" "$BASE") RESOURCE_TYPE="$(echo "$RESULT" | jq -r .resourceType)" if [ "$RESOURCE_TYPE" = "Bundle" ]; then - echo "OK πŸ‘: the resource type is Bundle" + echo "βœ… the resource type is Bundle" else - echo "Fail 😞: the resource type is $RESOURCE_TYPE, expected Bundle" + echo "πŸ†˜ the resource type is $RESOURCE_TYPE, expected Bundle" exit 1 fi BUNDLE_TYPE="$(echo "$RESULT" | jq -r .type)" if [ "$BUNDLE_TYPE" = "batch-response" ]; then - echo "OK πŸ‘: the bundle type is batch-response" + echo "βœ… the bundle type is batch-response" else - echo "Fail 😞: the bundle type is $BUNDLE_TYPE, expected batch-response" + echo "πŸ†˜ the bundle type is $BUNDLE_TYPE, expected batch-response" exit 1 fi RESPONSE_STATUS="$(echo "$RESULT" | jq -r .entry[].response.status)" if [ "$RESPONSE_STATUS" = "200" ]; then - echo "OK πŸ‘: the response status is 200" + echo "βœ… the response status is 200" else - echo "Fail 😞: the response status is $RESPONSE_STATUS, expected 200" + echo "πŸ†˜ the response status is $RESPONSE_STATUS, expected 200" exit 1 fi RESPONSE_RESOURCE_TYPE="$(echo "$RESULT" | jq -r .entry[].resource.resourceType)" if [ "$RESPONSE_RESOURCE_TYPE" = "CapabilityStatement" ]; then - echo "OK πŸ‘: resource type is CapabilityStatement" + echo "βœ… resource type is CapabilityStatement" else - echo "Fail 😞: resource type was $RESPONSE_RESOURCE_TYPE but should be CapabilityStatement" + echo "πŸ†˜ resource type was $RESPONSE_RESOURCE_TYPE but should be CapabilityStatement" exit 1 fi diff --git a/.github/scripts/batch.sh b/.github/scripts/batch.sh index 3f3a4cf16..eb3907764 100755 --- a/.github/scripts/batch.sh +++ b/.github/scripts/batch.sh @@ -29,32 +29,32 @@ RESULT=$(curl -sH "Content-Type: application/fhir+json" -d "$(bundle)" "$BASE") RESOURCE_TYPE="$(echo "$RESULT" | jq -r .resourceType)" if [ "$RESOURCE_TYPE" = "Bundle" ]; then - echo "OK πŸ‘: the resource type is Bundle" + echo "βœ… the resource type is Bundle" else - echo "Fail 😞: the resource type is $RESOURCE_TYPE, expected Bundle" + echo "πŸ†˜ the resource type is $RESOURCE_TYPE, expected Bundle" exit 1 fi BUNDLE_TYPE="$(echo "$RESULT" | jq -r .type)" if [ "$BUNDLE_TYPE" = "batch-response" ]; then - echo "OK πŸ‘: the bundle type is batch-response" + echo "βœ… the bundle type is batch-response" else - echo "Fail 😞: the bundle type is $BUNDLE_TYPE, expected batch-response" + echo "πŸ†˜ the bundle type is $BUNDLE_TYPE, expected batch-response" exit 1 fi RESPONSE_STATUS="$(echo "$RESULT" | jq -r .entry[].response.status)" if [ "$RESPONSE_STATUS" = "200" ]; then - echo "OK πŸ‘: the response status is 200" + echo "βœ… the response status is 200" else - echo "Fail 😞: the response status is $RESPONSE_STATUS, expected 200" + echo "πŸ†˜ the response status is $RESPONSE_STATUS, expected 200" exit 1 fi RESPONSE_PATIENT_ID="$(echo "$RESULT" | jq -r .entry[].resource.id)" if [ "$RESPONSE_PATIENT_ID" = "$PATIENT_ID" ]; then - echo "OK πŸ‘: patient id's match" + echo "βœ… patient id's match" else - echo "Fail 😞: response patient id was $RESPONSE_PATIENT_ID but should be $PATIENT_ID" + echo "πŸ†˜ response patient id was $RESPONSE_PATIENT_ID but should be $PATIENT_ID" exit 1 fi diff --git a/.github/scripts/cancel-async.sh b/.github/scripts/cancel-async.sh index eabb6dfde..4c3f5988e 100755 --- a/.github/scripts/cancel-async.sh +++ b/.github/scripts/cancel-async.sh @@ -17,7 +17,7 @@ test "after cancel issue code" "$(echo "$RESPONSE" | jq -r '.issue[0].code')" "n DIAGNOSTICS="$(curl -s -H 'Accept: application/fhir+json' "$STATUS_URL" | jq -r '.issue[0].diagnostics')" if [[ "$DIAGNOSTICS" =~ The\ asynchronous\ request\ with\ id\ \`[A-Z0-9]+\`\ is\ cancelled. ]]; then - echo "OK πŸ‘: the diagnostics message is right" + echo "βœ… the diagnostics message is right" else - echo "Fail 😞: the diagnostics message is $DIAGNOSTICS, expected /The asynchronous request with id \`[A-Z0-9]\+\` is cancelled./" + echo "πŸ†˜ the diagnostics message is $DIAGNOSTICS, expected /The asynchronous request with id \`[A-Z0-9]\+\` is cancelled./" fi diff --git a/.github/scripts/chaining-without-referential-integrity.sh b/.github/scripts/chaining-without-referential-integrity.sh index e4b442314..738a0abd0 100755 --- a/.github/scripts/chaining-without-referential-integrity.sh +++ b/.github/scripts/chaining-without-referential-integrity.sh @@ -8,8 +8,8 @@ curl -sXPUT -d '{"resourceType" : "Patient", "id": "0", "gender": "male"}' -H 'C RESULT="$(curl -sH 'Prefer: handling=strict' -H 'Accept: application/fhir+json' "$BASE/Observation?patient.gender=male&_summary=count" | jq -r '.total')" if [ "$RESULT" = "1" ]; then - echo "OK πŸ‘: chaining works" + echo "βœ… chaining works" else - echo "Fail 😞: chaining doesn't work" + echo "πŸ†˜ chaining doesn't work" exit 1 fi diff --git a/.github/scripts/check-date-search.sh b/.github/scripts/check-date-search.sh index d8e04edd0..14b6e16be 100755 --- a/.github/scripts/check-date-search.sh +++ b/.github/scripts/check-date-search.sh @@ -20,7 +20,7 @@ size() { if [ "$DOWNLOAD_SIZE" = "$COUNT_SIZE" ]; then echo "$DOWNLOAD_SIZE" else - echo "Fail 😞: the download size is $DOWNLOAD_SIZE, expected $COUNT_SIZE" + echo "πŸ†˜ the download size is $DOWNLOAD_SIZE, expected $COUNT_SIZE" exit 1 fi } diff --git a/.github/scripts/check-referential-integrity-enforced.sh b/.github/scripts/check-referential-integrity-enforced.sh index 57f423ab5..d8e627aa8 100755 --- a/.github/scripts/check-referential-integrity-enforced.sh +++ b/.github/scripts/check-referential-integrity-enforced.sh @@ -3,7 +3,7 @@ ENFORCED=$(curl -s http://localhost:8080/fhir/metadata | jq -r 'isempty(.rest[].resource[].referencePolicy[] | select(. == "enforced")) | not') if [ "true" = "$ENFORCED" ]; then - echo "OK πŸ‘" + echo "βœ…" else echo "Fail 😞" exit 1 diff --git a/.github/scripts/check-referential-integrity-not-enforced.sh b/.github/scripts/check-referential-integrity-not-enforced.sh index 86179be6c..b08dc4e9e 100755 --- a/.github/scripts/check-referential-integrity-not-enforced.sh +++ b/.github/scripts/check-referential-integrity-not-enforced.sh @@ -3,7 +3,7 @@ ENFORCED=$(curl -s http://localhost:8080/fhir/metadata | jq -r 'isempty(.rest[].resource[].referencePolicy[] | select(. == "enforced")) | not') if [ "false" = "$ENFORCED" ]; then - echo "OK πŸ‘" + echo "βœ…" else echo "Fail 😞" exit 1 diff --git a/.github/scripts/conditional-create.sh b/.github/scripts/conditional-create.sh index 2b487f99c..c3fb8fde9 100755 --- a/.github/scripts/conditional-create.sh +++ b/.github/scripts/conditional-create.sh @@ -34,9 +34,9 @@ STATUS=$(curl -sH "Content-Type: application/fhir+json" \ -d "$(bundle)" "$BASE" | jq -r '.entry[].response.status') if [ "$STATUS" = "201" ]; then - echo "OK πŸ‘: first attempt created the Organization" + echo "βœ… first attempt created the Organization" else - echo "Fail 😞: status was ${STATUS} but should be 201" + echo "πŸ†˜ status was ${STATUS} but should be 201" exit 1 fi @@ -44,8 +44,8 @@ STATUS=$(curl -sH "Content-Type: application/fhir+json" \ -d "$(bundle)" "$BASE" | jq -r '.entry[].response.status') if [ "$STATUS" = "200" ]; then - echo "OK πŸ‘: second attempt returned the already created Organization" + echo "βœ… second attempt returned the already created Organization" else - echo "Fail 😞: status was ${STATUS} but should be 200" + echo "πŸ†˜ status was ${STATUS} but should be 200" exit 1 fi diff --git a/.github/scripts/delete-history.sh b/.github/scripts/delete-history.sh new file mode 100755 index 000000000..ec348fab8 --- /dev/null +++ b/.github/scripts/delete-history.sh @@ -0,0 +1,95 @@ +#!/bin/bash -e + +# +# This script does the following: +# +# * creates a patient +# * updates that patient to create a second version +# * expects the history to contain two entries +# * deletes the history of the patient +# * expects the history to contain only the current entry +# * expects a versioned read of the deleted history entry to return 404 +# * expects the type history doesn't contain the deleted history entry +# * expects the system history doesn't contain the deleted history entry +# + +SCRIPT_DIR="$(dirname "$(readlink -f "$0")")" +. "$SCRIPT_DIR/util.sh" + +BASE="http://localhost:8080/fhir" + +patient() { +cat < /dev/null | grep Diagnostics | cut -d: -f2 | xargs) if [ "$DIAGNOSTICS" = "Timeout of 10 millis eclipsed while evaluating." ]; then - echo "OK πŸ‘: timeout happened" + echo "βœ… timeout happened" else - echo "Fail 😞: no timeout" + echo "πŸ†˜ no timeout" exit 1 fi diff --git a/.github/scripts/evaluate-measure.sh b/.github/scripts/evaluate-measure.sh index 39318ea13..1fe51198e 100755 --- a/.github/scripts/evaluate-measure.sh +++ b/.github/scripts/evaluate-measure.sh @@ -20,9 +20,9 @@ REPORT=$(evaluate_measure "$BASE" "$MEASURE_URI") COUNT=$(echo "$REPORT" | jq -r ".group[0].population[0].count") if [ "$COUNT" = "$EXPECTED_COUNT" ]; then - echo "OK πŸ‘: count ($COUNT) equals the expected count" + echo "βœ… count ($COUNT) equals the expected count" else - echo "Fail 😞: count ($COUNT) != $EXPECTED_COUNT" + echo "πŸ†˜ count ($COUNT) != $EXPECTED_COUNT" echo "Report:" echo "$REPORT" | jq . exit 1 diff --git a/.github/scripts/evaluate-patient-q1-measure.sh b/.github/scripts/evaluate-patient-q1-measure.sh index 0462dc6b2..44ba00361 100755 --- a/.github/scripts/evaluate-patient-q1-measure.sh +++ b/.github/scripts/evaluate-patient-q1-measure.sh @@ -87,17 +87,17 @@ MEASURE_ID=$(create-measure "$MEASURE_URI" "$LIBRARY_URI" | create "$BASE/Measur MALE_PATIENT_ID=$(curl -s "$BASE/Patient?gender=male&_count=1" | jq -r '.entry[].resource.id') COUNT=$(evaluate-measure "$BASE" "$MEASURE_ID" "$MALE_PATIENT_ID" | jq -r ".group[0].population[0].count") if [ "$COUNT" = "1" ]; then - echo "OK πŸ‘: count ($COUNT) equals the expected count" + echo "βœ… count ($COUNT) equals the expected count" else - echo "Fail 😞: count ($COUNT) != 1" + echo "πŸ†˜ count ($COUNT) != 1" exit 1 fi FEMALE_PATIENT_ID=$(curl -s "$BASE/Patient?gender=female&_count=1" | jq -r ".entry[].resource.id") COUNT=$(evaluate-measure "$BASE" "$MEASURE_ID" "$FEMALE_PATIENT_ID" | jq -r ".group[0].population[0].count") if [ "$COUNT" = "0" ]; then - echo "OK πŸ‘: count ($COUNT) equals the expected count" + echo "βœ… count ($COUNT) equals the expected count" else - echo "Fail 😞: count ($COUNT) != 0" + echo "πŸ†˜ count ($COUNT) != 0" exit 1 fi diff --git a/.github/scripts/forwarded-header.sh b/.github/scripts/forwarded-header.sh index 3b193aeaf..9df8a6bf9 100755 --- a/.github/scripts/forwarded-header.sh +++ b/.github/scripts/forwarded-header.sh @@ -5,8 +5,8 @@ EXPECTED_SELF_LINK="$PROTO://blaze.de/fhir/Patient" ACTUAL_SELF_LINK=$(curl -s -H "Forwarded:host=blaze.de;proto=$PROTO" http://localhost:8080/fhir/Patient | jq -r '.link[] | select(.relation == "self") | .url' | cut -d? -f1) if [ "$EXPECTED_SELF_LINK" = "$ACTUAL_SELF_LINK" ]; then - echo "OK πŸ‘" + echo "βœ…" else - echo "Fail 😞: expected '$EXPECTED_SELF_LINK' but was '$ACTUAL_SELF_LINK'" + echo "πŸ†˜ expected '$EXPECTED_SELF_LINK' but was '$ACTUAL_SELF_LINK'" exit 1 fi diff --git a/.github/scripts/include-without-referential-integrity.sh b/.github/scripts/include-without-referential-integrity.sh index ae87c96ab..31dbf5bc1 100755 --- a/.github/scripts/include-without-referential-integrity.sh +++ b/.github/scripts/include-without-referential-integrity.sh @@ -8,8 +8,8 @@ curl -sXPUT -d '{"resourceType" : "Patient", "id": "0"}' -H 'Content-Type: appli RESULT=$(curl -s "$BASE/Observation?_include=Observation:subject" | jq -r '.entry[].search.mode' | tr '\n' '|') if [ "$RESULT" = "match|include|" ]; then - echo "OK πŸ‘: include works" + echo "βœ… include works" else - echo "Fail 😞: include doesn't work" + echo "πŸ†˜ include doesn't work" exit 1 fi diff --git a/.github/scripts/not-acceptable.sh b/.github/scripts/not-acceptable.sh index 19222e584..58684428f 100755 --- a/.github/scripts/not-acceptable.sh +++ b/.github/scripts/not-acceptable.sh @@ -3,7 +3,7 @@ BASE="http://localhost:8080/fhir" if [ "406" = "$(curl -s -o /dev/null -w '%{response_code}' -H 'Accept: text/plain' "$BASE")" ]; then - echo "OK πŸ‘: text/plain is a not acceptable media type" + echo "βœ… text/plain is a not acceptable media type" else echo "Fail 😞" exit 1 diff --git a/.github/scripts/revinclude.sh b/.github/scripts/revinclude.sh index 6a5b4b1f1..caffe57ff 100755 --- a/.github/scripts/revinclude.sh +++ b/.github/scripts/revinclude.sh @@ -18,20 +18,20 @@ ACTUAL_NUM_PROCEDURES=$(jq -r .resourceType output.ndjson | grep -c Procedure) rm output.ndjson if [ "$EXPECTED_NUM_PATIENTS" != "$ACTUAL_NUM_PATIENTS" ]; then - echo "Fail 😞: Patient download size was ${ACTUAL_NUM_PATIENTS} but should be ${EXPECTED_NUM_PATIENTS}" + echo "πŸ†˜ Patient download size was ${ACTUAL_NUM_PATIENTS} but should be ${EXPECTED_NUM_PATIENTS}" exit 1 elif [ "$EXPECTED_NUM_OBSERVATIONS" != "$ACTUAL_NUM_OBSERVATIONS" ]; then - echo "Fail 😞: Observation download size was ${ACTUAL_NUM_OBSERVATIONS} but should be ${EXPECTED_NUM_OBSERVATIONS}" + echo "πŸ†˜ Observation download size was ${ACTUAL_NUM_OBSERVATIONS} but should be ${EXPECTED_NUM_OBSERVATIONS}" exit 1 elif [ "$EXPECTED_NUM_CONDITIONS" != "$ACTUAL_NUM_CONDITIONS" ]; then - echo "Fail 😞: Condition download size was ${ACTUAL_NUM_CONDITIONS} but should be ${EXPECTED_NUM_CONDITIONS}" + echo "πŸ†˜ Condition download size was ${ACTUAL_NUM_CONDITIONS} but should be ${EXPECTED_NUM_CONDITIONS}" exit 1 elif [ "$EXPECTED_NUM_ENCOUNTERS" != "$ACTUAL_NUM_ENCOUNTERS" ]; then - echo "Fail 😞: Encounter download size was ${ACTUAL_NUM_ENCOUNTERS} but should be ${EXPECTED_NUM_ENCOUNTERS}" + echo "πŸ†˜ Encounter download size was ${ACTUAL_NUM_ENCOUNTERS} but should be ${EXPECTED_NUM_ENCOUNTERS}" exit 1 elif [ "$EXPECTED_NUM_PROCEDURES" != "$ACTUAL_NUM_PROCEDURES" ]; then - echo "Fail 😞: Procedure download size was ${ACTUAL_NUM_PROCEDURES} but should be ${EXPECTED_NUM_PROCEDURES}" + echo "πŸ†˜ Procedure download size was ${ACTUAL_NUM_PROCEDURES} but should be ${EXPECTED_NUM_PROCEDURES}" exit 1 else - echo "OK πŸ‘: all download sizes match" + echo "βœ… all download sizes match" fi diff --git a/.github/scripts/search-compartment.sh b/.github/scripts/search-compartment.sh index 9fdec3d10..3022db92a 100755 --- a/.github/scripts/search-compartment.sh +++ b/.github/scripts/search-compartment.sh @@ -5,8 +5,8 @@ PATIENT_ID=$(curl -s "$BASE/Patient?identifier=http://hl7.org/fhir/sid/us-ssn|99 OBSERVATION_COUNT=$(curl -s "$BASE/Patient/$PATIENT_ID/Observation?_summary=count" | jq .total) if [ "$OBSERVATION_COUNT" = "1277" ]; then - echo "OK πŸ‘: lab count ($OBSERVATION_COUNT) equals the expected count" + echo "βœ… lab count ($OBSERVATION_COUNT) equals the expected count" else - echo "Fail 😞: lab count ($OBSERVATION_COUNT) != 1277" + echo "πŸ†˜ lab count ($OBSERVATION_COUNT) != 1277" exit 1 fi diff --git a/.github/scripts/search-patient-last-updated.sh b/.github/scripts/search-patient-last-updated.sh index 6451bad2d..a50300e40 100755 --- a/.github/scripts/search-patient-last-updated.sh +++ b/.github/scripts/search-patient-last-updated.sh @@ -11,8 +11,8 @@ NOW=$(date +%Y-%m-%dT%H:%M:%S) PATIENT_COUNT=$(curl -sH 'Prefer: handling=strict' "$BASE/Patient?_lastUpdated=gt$NOW&_summary=count" | jq -r .total) if [ "$PATIENT_COUNT" -eq 0 ]; then - echo "OK πŸ‘: no patents are updated after $NOW" + echo "βœ… no patents are updated after $NOW" else - echo "Fail 😞: $PATIENT_COUNT patents are updated after $NOW" + echo "πŸ†˜ $PATIENT_COUNT patents are updated after $NOW" exit 1 fi diff --git a/.github/scripts/util.sh b/.github/scripts/util.sh index 0639132f7..602f369e1 100755 --- a/.github/scripts/util.sh +++ b/.github/scripts/util.sh @@ -2,36 +2,45 @@ test() { if [ "$2" = "$3" ]; then - echo "OK πŸ‘: the $1 is $3" + echo "βœ… the $1 is $3" else - echo "Fail 😞: the $1 is $2, expected $3" + echo "πŸ†˜ the $1 is $2, expected $3" + exit 1 + fi +} + +test_not_equal() { + if [ "$2" != "$3" ]; then + echo "βœ… the $1 is not $3" + else + echo "πŸ†˜ the $1 is $2, expected not $3" exit 1 fi } test-regex() { if [[ "$2" =~ $3 ]]; then - echo "OK πŸ‘: the $1 matches $3" + echo "βœ… the $1 matches $3" else - echo "Fail 😞: the $1 is $2, expected matching $3" + echo "πŸ†˜ the $1 is $2, expected matching $3" exit 1 fi } test-le() { if [ "$2" -le "$3" ]; then - echo "OK πŸ‘: the $1 of $2 is <= $3" + echo "βœ… the $1 of $2 is <= $3" else - echo "Fail 😞: the $1 is $2, expected <= $3" + echo "πŸ†˜ the $1 is $2, expected <= $3" exit 1 fi } test_empty() { if [ -z "$2" ]; then - echo "OK πŸ‘: the $1 is empty" + echo "βœ… the $1 is empty" else - echo "Fail 😞: the $1 is $2, should be empty" + echo "πŸ†˜ the $1 is $2, should be empty" exit 1 fi } diff --git a/.github/scripts/x-forwarded-headers.sh b/.github/scripts/x-forwarded-headers.sh index 027f03159..580be8ca3 100755 --- a/.github/scripts/x-forwarded-headers.sh +++ b/.github/scripts/x-forwarded-headers.sh @@ -5,8 +5,8 @@ EXPECTED_SELF_LINK="$PROTO://blaze.de/fhir/Patient" ACTUAL_SELF_LINK=$(curl -s -H 'X-Forwarded-Host:blaze.de' -H "X-Forwarded-Proto:$PROTO" http://localhost:8080/fhir/Patient | jq -r '.link[] | select(.relation == "self") | .url' | cut -d? -f1) if [ "$EXPECTED_SELF_LINK" = "$ACTUAL_SELF_LINK" ]; then - echo "OK πŸ‘" + echo "βœ…" else - echo "Fail 😞: expected '$EXPECTED_SELF_LINK' but was '$ACTUAL_SELF_LINK'" + echo "πŸ†˜ expected '$EXPECTED_SELF_LINK' but was '$ACTUAL_SELF_LINK'" exit 1 fi diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 886b5e9ae..3575a616d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1067,6 +1067,9 @@ jobs: - name: Create Patient With Ancient Birth Year run: .github/scripts/create-patient-with-ancient-birth-year.sh + - name: Delete History + run: .github/scripts/delete-history.sh + - name: Prometheus Metrics run: .github/scripts/test-metrics.sh if: ${{ matrix.variant == 'standalone' }} @@ -1494,6 +1497,9 @@ jobs: - name: Jepsen Register Test run: make -C modules/jepsen register-test-fast + - name: Jepsen Resource History Test + run: make -C modules/jepsen resource-history-test-fast + openid-auth-test: needs: build runs-on: ubuntu-22.04 @@ -1901,6 +1907,9 @@ jobs: - name: Jepsen Register Test run: make -C modules/jepsen register-test-slow + - name: Jepsen Resource History Test + run: make -C modules/jepsen resource-history-test-slow + - name: Docker Stats run: docker stats --no-stream diff --git a/docs/api.md b/docs/api.md index 6417059c0..0b41984ab 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1,17 +1,116 @@ # FHIR RESTful API -Blaze exposes a [FHIR RESTful API][1] under the default context path of `/fhir`. The [CapabilityStatement][2] exposed under `/fhir/metadata` can be used to discover the capabilities of Blaze. Everything stated there can be considered to be implemented correctly. If not please [file an issue][3]. +Blaze exposes a [FHIR RESTful API][1] under the default context path of `/fhir`. The [CapabilityStatement][2] exposed under `/fhir/metadata` can be used to discover the capabilities of Blaze. Everything stated there can be considered to be implemented correctly. If you find any discrepancies, please open an [issue][3]. ## Interactions +### Read + +``` +GET [base]/[type]/[id] +``` + +### Versioned Read + +``` +GET [base]/[type]/[id]/_history/[vid] +``` + ### Update +The update interaction creates a new current version for an existing resource or creates an initial version if no resource already exists for the given id. + +``` +PUT [base]/[type]/[id] +``` + Blaze keeps track over the history of all updates of each resource. However if the content of the resource update is equal to the current version of the resource, no new history entry is created. Usually such identical content updates will only cost a very small amount of transaction handling storage but no additional resource or index storage. +### Conditional Update + +Conditional update interaction will be implemented in the future. Please see issue [#361](https://github.com/samply/blaze/issues/361) for more information. + +### Patch + +The patch interaction isn't implemented yet. Please open an [issue][3] if you need patch. + +### Conditional Patch + +The conditional patch interaction isn't implemented yet. Please open an [issue][3] if you need patch. + ### Delete +The delete interaction removes an existing resource. + +``` +DELETE [base]/[type]/[id] +``` + +Because Blaze keeps a history of all resource versions, the version of the resource past the deletion will be still accessible in it's [history](#history-instance). However past versions can be deleted themself via the [delete history](#delete-history) interaction. + By default Blaze enforces referential integrity while deleting resources. So resources that are referred by other resources can't be deleted without deleting the other resources first. That behaviour can be changed by setting the [environment variable](deployment/environment-variables.md) `ENFORCE_REFERENTIAL_INTEGRITY` to `false`. +### Delete History + +> [!CAUTION] +> The delete history interaction is trial use in the unreleased next version of FHIR. So it is subject to change. Please use it with care. + +The delete history interaction removes all versions of the resource except the current version. + +``` +DELETE [base]/[type]/[id]/_history +``` + +Deleting the history of a resource means that the historical versions of that resource can no longer be accessed. Subsequent [versioned reads](#versioned-read) of that historical versions will return a `404 Not Found`. Versions are also removed from the [type history](#history-type) and [system history](#history-system). Only active [paging sessions](#paging-sessions) will be able to access the deleted versions for a limited amount of time. + +> [!NOTE] +> Due to stability concerns, there is a fix limit of 100,000 versions that can be deleted by this interaction. In case more than 100,000 versions exist, an OperationOutcome with code `too-costly` is returned. Currently there is no way to delete a history with more than 100,000 versions. Please open an [issue][3] if you need to delete larger histories. + +### History Instance + +The history instance interaction retrieves the history of a particular resource. + +``` +GET [base]/[type]/[id]/_history +``` + +The return content is a Bundle with type set to `history` containing the versions of the resource in question, sorted with newest versions first, and including versions of resource deletions. The history instance interaction supports paging which is described in depth in the separate [paging sessions](#paging-sessions) section. + +### Create + +The create interaction creates a new resource with a server-assigned id. If the client wishes to have control over the id of a newly submitted resource, it should use the [update](#update) interaction instead. + +``` +POST [base]/[type] +``` + +### Conditional Create + +It's currently possible to use conditional create in transaction or batch requests. + +### Search Type + +``` +GET [base]/[type]?param1=value&... +POST [base]/[type]/_search +``` + +#### _profile + +Search for `Resource.meta.profile` is supported using the `_profile` search param with exact match or using the `below` modifier as in `_profile:below` with major and minor version prefix match. [Semver][6] is expected for version numbers so a search value of `|1` will find all versions with major version `1` and a search value of `|1.2` will find all versions with major version `1` and minor version `2`. Patch versions are not supported with the `below` modifier. + +#### Date Search + +When searching for date/time with a search parameter value without timezone like `2024` or `2024-02-16`, Blaze calculates the range of the search parameter values based on [UTC][7]. That means that a resource with a date/time value staring at `2024-01-01T00:00:00+01:00` will be not found by a search with `2024`. Please comment on [issue #1498](https://github.com/samply/blaze/issues/1498) if you like to have this situation improved. + +#### Sorting + +The special search parameter `_sort` supports the values `_id`, `_lastUpdated` and `-_lastUpdated`. + +#### Paging + +The search-type interaction supports paging which is described in depth in the separate [paging sessions](#paging-sessions) section. + ### Conditional Delete The conditional delete operation allows to delete all resources matching certain criteria. The same search parameter as in the search type interaction can be used. The search is always strict, so it will fail on any unknown search parameter. @@ -20,10 +119,10 @@ The conditional delete operation allows to delete all resources matching certain DELETE [base]/[type]?[search parameters] ``` -By default, the delete is only performed if one resource matches. However it's possible to allow deleting multiple resources by setting the [environment variable](deployment/environment-variables.md) `ALLOW_MULTIPLE_DELETE` to `true`. +By default, the delete is only performed if one resource matches. However it's possible to allow deleting multiple resources by setting the [environment variable](deployment/environment-variables.md) `ALLOW_MULTIPLE_DELETE` to `true`. > [!NOTE] -> Due to stability concerns, there is a fix limit of 10,000 resources that can be deleted by this interaction. In case more than 10,000 resources match, an OperationOutcome with code `too-costly` is returned. +> Due to stability concerns, there is a fix limit of 10,000 resources that can be deleted by this interaction. In case more than 10,000 resources match, an OperationOutcome with code `too-costly` is returned. The successful response will have the status code `204 No Content` with no payload by default. However it's possible to specify a return preference of `OperationOutcome` by setting the `Prefer` header to `return=OperationOutcome`. In this case a success OperationOutcome with a diagnostic of the number of deleted resources is returned. @@ -42,27 +141,45 @@ The successful response will have the status code `204 No Content` with no paylo } ``` -### Search Type +### History Type -#### _profile +The history type interaction retrieves the history of all resources of a particular type. -Search for `Resource.meta.profile` is supported using the `_profile` search param with exact match or using the `below` modifier as in `_profile:below` with major and minor version prefix match. [Semver][6] is expected for version numbers so a search value of `|1` will find all versions with major version `1` and a search value of `|1.2` will find all versions with major version `1` and minor version `2`. Patch versions are not supported with the `below` modifier. +``` +GET [base]/[type]/_history +``` -#### Date Search +The return content is a Bundle with type set to `history` containing the versions of the resources in question, sorted with newest versions first, and including versions of resource deletions. The history type interaction supports paging which is described in depth in the separate [paging sessions](#paging-sessions) section. -When searching for date/time with a search parameter value without timezone like `2024` or `2024-02-16`, Blaze calculates the range of the search parameter values based on [UTC][7]. That means that a resource with a date/time value staring at `2024-01-01T00:00:00+01:00` will be not found by a search with `2024`. Please comment on [issue #1498](https://github.com/samply/blaze/issues/1498) if you like to have this situation improved. +### Capabilities -#### Sorting +Get the capability statement for Blaze. Blaze supports filtering the capability statement by `_elements`. For more information, see: [FHIR - RESTful API - Capabilities][5] -The special search parameter `_sort` supports the values `_id`, `_lastUpdated` and `-_lastUpdated`. +### Batch -#### Paging +The batch interaction allows to submit a set of actions to be performed as a single HTTP request. The semantics of the individual actions described in the `batch` Bundle are identical of the semantics of the corresponding individual request. The actions are performed in order but can be interleaved by other individual requests or actions from other batch interactions. -The search-type interaction supports paging which is described in depth in the separate [paging section](#paging-1). +``` +POST [base] +``` -### Capabilities +### Transaction -Get the capability statement for Blaze. Blaze supports filtering the capability statement by `_elements`. For more information, see: [FHIR - RESTful API - Capabilities][5] +The transaction interaction allows to submit a set of actions to be performed inside a single transaction. Blaze supports the full set of ACID (atomicity, consistency, isolation, durability) properties. Transactions are always performed atomically and in isolation. Actions from other individual requests or actions from batch interactions will not interleave with any actions from a transaction interaction. + +``` +POST [base] +``` + +### History System + +The history system interaction retrieves the history of all resources of the whole system. + +``` +GET [base]/_history +``` + +The return content is a Bundle with type set to `history` containing the versions of the resources in question, sorted with newest versions first, and including versions of resource deletions. The history system interaction supports paging which is described in depth in the separate [paging sessions](#paging-sessions) section. ## Operations @@ -156,17 +273,25 @@ Async requests can be cancelled before they are completed: curl -svXDELETE "http://localhost:8080/fhir/__async-status/DD7MLX6H7OGJN7SD" ``` -## Paging +## Paging Sessions + +Interactions and operations that return a large list of resources support paging via Bundle resources. The various Bundle resources are interlinked via the next link. The process of retrieving a part of or all Bundle resources (pages) of a large response forms a paging session. Paging sessions have the following properties: + +### Stable + +Paging sessions operate on a stable database snapshot. Next links will point to custom paging session endpoints. The endpoints will expire after for 4 hours in order to constrain the usage of a paging session. That also means that clients which have access to a paging session, will be able to access deleted and changed resources for up to 4 hours. + +### Expire -Interactions and operations that return a large list of resources support paging via Bundle resources. The various Bundle resources are interlinked via the next link. The paging has the following properties: +Paging sessions will expire after 4 hours without activity. Activities are requesting the first or next page. -### Paging is Stable +### Fast -The initial request operates on the newest database snapshot available and all pages accessible via next links will continue to use the same database snapshot. Next links will point to custom paging endpoints. The endpoints will expire after for 4 hours in order to constrain the access to old database snapshots. That also means that clients which hold paging URLs will be able to access deleted and changed resources for up to 4 hours. +Paging sessions don't require initial setup time and show constant costs per page in most cases. In fact paging sessions don't require book keeping at server side at all. Their state is solely communicated via link URLs. For search requests with more than one parameter, page costs can vary because of internal query handling. -### Paging URLs are Encrypted +### Encrypted -The variable part of paging URLs is encrypted to ensure confidentiality and integrity of the paging parameters. Confidentiality is important in case some of the original query parameters contain sensitive information. To mitigate the risk of exposing this data, FHIR searches are often executed via POST requests, which helps prevent sensitive information from being logged in URLs. Consequently, it is essential that paging URLs do not reveal any confidential data. Integrity is important, because it should not be possible to manipulate the paging URL in order to access a different database snapshot. +The variable part of paging URLs is encrypted to ensure confidentiality and integrity of the paging parameters. Confidentiality is important in case some of the original query parameters contain sensitive information. To mitigate the risk of exposing this data, FHIR searches are often executed via POST requests, which helps prevent sensitive information from being logged in URLs. Consequently, it is essential that paging URLs do not reveal any confidential data. Integrity is important, because it should not be possible to manipulate the paging URL in order to access a different paging session. #### Encryption Key Management diff --git a/modules/db-tx-log/src/blaze/db/tx_log/spec.clj b/modules/db-tx-log/src/blaze/db/tx_log/spec.clj index b919ac791..971618238 100644 --- a/modules/db-tx-log/src/blaze/db/tx_log/spec.clj +++ b/modules/db-tx-log/src/blaze/db/tx_log/spec.clj @@ -19,7 +19,7 @@ queue?) (s/def :blaze.db.tx-cmd/op - #{"create" "put" "keep" "delete" "conditional-delete"}) + #{"create" "put" "keep" "delete" "conditional-delete" "delete-history"}) (s/def :blaze.db.tx-cmd/type :fhir.resource/type) @@ -87,6 +87,11 @@ :blaze.db.tx-cmd/check-refs :blaze.db.tx-cmd/allow-multiple])) +(defmethod tx-cmd "delete-history" [_] + (s/keys :req-un [:blaze.db.tx-cmd/op + :blaze.db.tx-cmd/type + :blaze.resource/id])) + (s/def :blaze.db/tx-cmd (s/multi-spec tx-cmd :op)) diff --git a/modules/db-tx-log/test/blaze/db/tx_log/spec_test.clj b/modules/db-tx-log/test/blaze/db/tx_log/spec_test.clj index 792398a91..5d53f9f2e 100644 --- a/modules/db-tx-log/test/blaze/db/tx_log/spec_test.clj +++ b/modules/db-tx-log/test/blaze/db/tx_log/spec_test.clj @@ -11,7 +11,7 @@ [taoensso.timbre :as log])) (st/instrument) -(log/set-level! :trace) +(log/set-min-level! :trace) (test/use-fixtures :each tu/fixture) @@ -36,11 +36,19 @@ :id "0" :hash observation-hash-0 :refs [["Patient" "0"]]} + {:op "put" + :type "Patient" + :id "0" + :hash patient-hash-0} {:op "put" :type "Patient" :id "0" :hash patient-hash-0 :if-match 1} + {:op "keep" + :type "Patient" + :id "0" + :hash patient-hash-0} {:op "delete" :type "Patient" :id "0" @@ -48,11 +56,29 @@ {:op "delete" :type "Patient" :id "0" - :check-refs true})) + :check-refs true} + {:op "conditional-delete" + :type "Patient"} + {:op "delete-history" + :type "Patient" + :id "0"})) (testing "invalid" (are [tx-cmd] (not (s/valid? :blaze.db/tx-cmd tx-cmd)) + nil + 1 + {:op "create" + :type "Patient" + :id "0"} + {:op "put" + :type "Patient" + :id "0"} + {:op "delete" + :type "Patient"} {:op "delete" :type "Patient" :id "0" - :check-refs "i should be a boolean"}))) + :check-refs "i should be a boolean"} + {:op "conditional-delete"} + {:op "delete-history" + :type "Patient"}))) diff --git a/modules/db/src/blaze/db/impl/batch_db.clj b/modules/db/src/blaze/db/impl/batch_db.clj index 4c1b926ce..fb67b95d7 100644 --- a/modules/db/src/blaze/db/impl/batch_db.clj +++ b/modules/db/src/blaze/db/impl/batch_db.clj @@ -146,17 +146,19 @@ (-instance-history [_ tid id start-t] (let [start-t (if (some-> start-t (<= t)) start-t t)] - (rao/instance-history snapshot tid id start-t))) + (rao/instance-history snapshot tid id t start-t))) - (-total-num-of-instance-changes [_ tid id since] - (let [end-t (or (some->> since (t-by-instant/t-by-instant snapshot)) 0)] - (rao/num-of-instance-changes snapshot tid id t end-t))) + (-total-num-of-instance-changes [db tid id since] + (count + (cond->> (rao/instance-history snapshot tid id t t) + since + (coll/eduction (p/-stop-history-at db since))))) ;; ---- Type-Level History Functions ---------------------------------------- (-type-history [_ tid start-t start-id] (let [start-t (if (some-> start-t (<= t)) start-t t)] - (tao/type-history snapshot tid start-t start-id))) + (tao/type-history snapshot tid t start-t start-id))) (-total-num-of-type-changes [_ type since] (let [tid (codec/tid type) @@ -168,7 +170,7 @@ (-system-history [_ start-t start-tid start-id] (let [start-t (if (some-> start-t (<= t)) start-t t)] - (sao/system-history snapshot start-t start-tid start-id))) + (sao/system-history snapshot t start-t start-tid start-id))) (-total-num-of-system-changes [_ since] (let [end-t (some->> since (t-by-instant/t-by-instant snapshot))] diff --git a/modules/db/src/blaze/db/impl/index/resource_as_of.clj b/modules/db/src/blaze/db/impl/index/resource_as_of.clj index 820e14d99..95ac38740 100644 --- a/modules/db/src/blaze/db/impl/index/resource_as_of.clj +++ b/modules/db/src/blaze/db/impl/index/resource_as_of.clj @@ -21,9 +21,12 @@ (def ^:private ^:const ^long max-key-size (+ except-id-key-size codec/max-id-size)) -(def ^:const ^long value-size +(def ^:const ^long min-value-size (+ hash/size codec/state-size)) +(def ^:const ^long max-value-size + (+ hash/size codec/state-size codec/t-size)) + (defn- focus-id! "Reduces the limit of `kb` in order to hide the t and focus on id solely." [kb] @@ -295,15 +298,16 @@ (rh/resource-handle! tid id (codec/descending-long (bb/get-long! kb tid-id-size)) vb))) (defn instance-history - "Returns a reducible collection of all versions of the resource with `tid` and - `id` starting at `start-t`. - - Versions are resource handles." - [snapshot tid id start-t] + "Returns a reducible collection of all historic resource handles of the + resource with `tid` and `id` of the database with the point in time `t` + starting at `start-t`." + [snapshot tid id t start-t] (let [tid-id-size (+ codec/tid-size (bs/size id))] - (i/prefix-entries snapshot :resource-as-of-index - (map (decoder tid (codec/id-string id) tid-id-size)) - tid-id-size (start-key tid id start-t)))) + (i/prefix-entries + snapshot :resource-as-of-index + (comp (map (decoder tid (codec/id-string id) tid-id-size)) + (take-while #(< (long t) (rh/purged-at %)))) + tid-id-size (start-key tid id start-t)))) (defn- resource-handle* [iter target-buf key-buf value-buf tid id t] (let [tid-id-size (+ codec/tid-size (bs/size id)) @@ -347,7 +351,7 @@ [snapshot tid id t] (let [target-buf (bb/allocate max-key-size) key-buf (bb/allocate max-key-size) - value-buf (bb/allocate value-size)] + value-buf (bb/allocate max-value-size)] (with-open [iter (kv/new-iterator snapshot :resource-as-of-index)] (resource-handle* iter target-buf key-buf value-buf tid id t)))) @@ -369,7 +373,7 @@ [snapshot t] (let [target-buf (bb/allocate max-key-size) key-buf (bb/allocate max-key-size) - value-buf (bb/allocate value-size) + value-buf (bb/allocate max-value-size) iter (kv/new-iterator snapshot :resource-as-of-index)] (comp (keep @@ -389,7 +393,7 @@ ([snapshot t tid id-extractor matcher] (let [target-buf (bb/allocate max-key-size) key-buf (bb/allocate max-key-size) - value-buf (bb/allocate value-size) + value-buf (bb/allocate max-value-size) iter (kv/new-iterator snapshot :resource-as-of-index)] (comp (keep @@ -399,10 +403,3 @@ (when (matcher input handle) handle)))) (closer iter))))) - -(defn num-of-instance-changes - "Returns the number of changes between `start-t` (inclusive) and `end-t` - (inclusive) of the resource with `tid` and `id` in `snapshot`." - [snapshot tid id start-t end-t] - (- (long (:num-changes (resource-handle snapshot tid id start-t) 0)) - (long (:num-changes (resource-handle snapshot tid id end-t) 0)))) diff --git a/modules/db/src/blaze/db/impl/index/resource_handle.clj b/modules/db/src/blaze/db/impl/index/resource_handle.clj index c78fa9974..1de20b776 100644 --- a/modules/db/src/blaze/db/impl/index/resource_handle.clj +++ b/modules/db/src/blaze/db/impl/index/resource_handle.clj @@ -11,7 +11,7 @@ (set! *warn-on-reflection* true) (set! *unchecked-math* :warn-on-boxed) -(deftype ResourceHandle [^int tid id ^long t hash ^long num-changes op] +(deftype ResourceHandle [^int tid id ^long t hash ^long num-changes op ^long purged-at] p/FhirType (-type [_] ;; TODO: maybe cache this @@ -29,6 +29,7 @@ :hash hash :num-changes num-changes :op op + :purged-at purged-at not-found)) Object @@ -61,6 +62,11 @@ :delete :put))) +(defn- get-purged-at! [vb] + (if (<= 8 (bb/remaining vb)) + (bb/get-long! vb) + Long/MAX_VALUE)) + (defn resource-handle! "Creates a new resource handle. @@ -74,7 +80,8 @@ t hash (state->num-changes state) - (state->op state)))) + (state->op state) + (get-purged-at! vb)))) (defn resource-handle? [x] (instance? ResourceHandle x)) @@ -116,6 +123,11 @@ [rh] (.-op ^ResourceHandle rh)) +(defn purged-at + {:inline (fn [rh] `(.-purged-at ~(with-meta rh {:tag `ResourceHandle})))} + [rh] + (.-purged-at ^ResourceHandle rh)) + (defn reference [rh] (str (codec/tid->type (tid rh)) "/" (id rh))) diff --git a/modules/db/src/blaze/db/impl/index/rts_as_of.clj b/modules/db/src/blaze/db/impl/index/rts_as_of.clj index 22e0bf3c9..0d4677c90 100644 --- a/modules/db/src/blaze/db/impl/index/rts_as_of.clj +++ b/modules/db/src/blaze/db/impl/index/rts_as_of.clj @@ -21,14 +21,27 @@ (identical? :create op) (Numbers/setBit 1) (identical? :delete op) (Numbers/setBit 0))) -(defn encode-value [hash num-changes op] - (-> (bb/allocate rao/value-size) - (hash/into-byte-buffer! hash) - (bb/put-long! (state num-changes op)) - bb/array)) +(defn- encode-value + ([hash num-changes op] + (-> (bb/allocate rao/min-value-size) + (hash/into-byte-buffer! hash) + (bb/put-long! (state num-changes op)) + bb/array)) + ([hash num-changes op purged-at] + (-> (bb/allocate rao/max-value-size) + (hash/into-byte-buffer! hash) + (bb/put-long! (state num-changes op)) + (bb/put-long! purged-at) + bb/array))) -(defn index-entries [tid id t hash num-changes op] - (let [value (encode-value hash num-changes op)] - [[:resource-as-of-index (rao/encode-key tid id t) value] - [:type-as-of-index (tao/encode-key tid t id) value] - [:system-as-of-index (sao/encode-key t tid id) value]])) +(defn index-entries + ([tid id t hash num-changes op] + (let [value (encode-value hash num-changes op)] + [[:resource-as-of-index (rao/encode-key tid id t) value] + [:type-as-of-index (tao/encode-key tid t id) value] + [:system-as-of-index (sao/encode-key t tid id) value]])) + ([tid id t hash num-changes op purged-at] + (let [value (encode-value hash num-changes op purged-at)] + [[:resource-as-of-index (rao/encode-key tid id t) value] + [:type-as-of-index (tao/encode-key tid t id) value] + [:system-as-of-index (sao/encode-key t tid id) value]]))) diff --git a/modules/db/src/blaze/db/impl/index/system_as_of.clj b/modules/db/src/blaze/db/impl/index/system_as_of.clj index 9774836eb..4898094d0 100644 --- a/modules/db/src/blaze/db/impl/index/system_as_of.clj +++ b/modules/db/src/blaze/db/impl/index/system_as_of.clj @@ -63,14 +63,15 @@ (Longs/toByteArray (codec/descending-long ^long start-t)))) (defn system-history - "Returns a reducible collection of all versions between `start-t` (inclusive), - `start-tid` (optional, inclusive) and `start-id` (optional, inclusive) of all - resources. - - Versions are resource handles." - [snapshot start-t start-tid start-id] - (i/entries snapshot :system-as-of-index (map (decoder)) - (bs/from-byte-array (start-key start-t start-tid start-id)))) + "Returns a reducible collection of all historic resource handles of the + database with the point in time `t` between `start-t` (inclusive), `start-tid` + (optional, inclusive) and `start-id` (optional, inclusive)." + [snapshot t start-t start-tid start-id] + (i/entries + snapshot :system-as-of-index + (comp (map (decoder)) + (filter #(< (long t) (rh/purged-at %)))) + (bs/from-byte-array (start-key start-t start-tid start-id)))) (defn changes "Returns a reducible collection of all resource handles changed at `t`." diff --git a/modules/db/src/blaze/db/impl/index/type_as_of.clj b/modules/db/src/blaze/db/impl/index/type_as_of.clj index 3066143c7..ea23f811a 100644 --- a/modules/db/src/blaze/db/impl/index/type_as_of.clj +++ b/modules/db/src/blaze/db/impl/index/type_as_of.clj @@ -54,9 +54,12 @@ bs/from-byte-buffer!))) (defn type-history - "Returns a reducible collection of all historic resource handles between - `start-t` (inclusive) and `start-id` (optional, inclusive) of resources with - `tid`." - [snapshot tid start-t start-id] - (i/prefix-entries snapshot :type-as-of-index (map (decoder tid)) - codec/tid-size (start-key tid start-t start-id))) + "Returns a reducible collection of all historic resource handles with type + `tid` of the database with the point in time `t` between `start-t` (inclusive) + and `start-id` (optional, inclusive)." + [snapshot tid t start-t start-id] + (i/prefix-entries + snapshot :type-as-of-index + (comp (map (decoder tid)) + (filter #(< (long t) (rh/purged-at %)))) + codec/tid-size (start-key tid start-t start-id))) diff --git a/modules/db/src/blaze/db/node/transaction.clj b/modules/db/src/blaze/db/node/transaction.clj index e1acda683..c16437273 100644 --- a/modules/db/src/blaze/db/node/transaction.clj +++ b/modules/db/src/blaze/db/node/transaction.clj @@ -87,6 +87,13 @@ allow-multiple-delete (assoc :allow-multiple true))}) +(defmethod prepare-op :delete-history + [_ [_ type id]] + {:blaze.db/tx-cmd + {:op "delete-history" + :type type + :id id}}) + (def ^:private split (juxt #(mapv :blaze.db/tx-cmd %) #(into {} (map :hash-resource) %))) diff --git a/modules/db/src/blaze/db/node/tx_indexer/verify.clj b/modules/db/src/blaze/db/node/tx_indexer/verify.clj index 1f3e18d87..72f3ea93d 100644 --- a/modules/db/src/blaze/db/node/tx_indexer/verify.clj +++ b/modules/db/src/blaze/db/node/tx_indexer/verify.clj @@ -207,6 +207,7 @@ refs))) (def ^:private inc-0 (fnil inc 0)) +(def ^:private minus-0 (fnil - 0)) (defmethod verify-tx-cmd "create" [_search-param-registry db-before t res {:keys [type id hash refs]}] @@ -327,6 +328,45 @@ (and op (not (identical? :delete op))) (update-in [:stats tid :total] (fnil dec 0)))))) +(def ^:private ^:const ^long delete-history-max 100000) + +(defn- too-many-history-entries-msg [type id] + (format "Deleting the history of `%s/%s` failed because there are more than %,d history entries." + type id delete-history-max)) + +(defn- too-many-history-entries-anom [type id] + (ba/conflict + (too-many-history-entries-msg type id) + :fhir/issue "too-costly")) + +(defn- instance-history [db type id] + (into [] (comp (drop 1) (take delete-history-max)) (d/instance-history db type id))) + +(defn- purge-entry [tid id t rh] + (rts/index-entries tid id (rh/t rh) (rh/hash rh) (rh/num-changes rh) (rh/op rh) t)) + +(defn- purge-entries [tid id t instance-history] + (into [] (mapcat (partial purge-entry tid id t)) instance-history)) + +(defn- add-delete-history-entries [entries tid id t instance-history] + (-> (update entries :entries into (purge-entries tid (codec/id-byte-string id) t instance-history)) + (update-in [:stats tid :num-changes] minus-0 (count instance-history)))) + +(defmethod verify-tx-cmd "delete-history" + [_ db-before t res {:keys [type id]}] + (log/trace "verify-tx-cmd :delete-history" (str type "/" id)) + (with-open [_ (prom/timer duration-seconds "verify-delete-history")] + (let [tid (codec/tid type) + instance-history (instance-history db-before type id)] + (cond + (empty? instance-history) res + + (= delete-history-max (count instance-history)) + (throw-anom (too-many-history-entries-anom type id)) + + :else + (add-delete-history-entries res tid id t instance-history))))) + (defmethod verify-tx-cmd :default [_search-param-registry _db-before _t res _tx-cmd] res) diff --git a/modules/db/src/blaze/db/node/validation.clj b/modules/db/src/blaze/db/node/validation.clj index d5514a8df..87d1d215c 100644 --- a/modules/db/src/blaze/db/node/validation.clj +++ b/modules/db/src/blaze/db/node/validation.clj @@ -23,6 +23,8 @@ (defmethod extract-type-id :conditional-delete [_]) +(defmethod extract-type-id :delete-history [_]) + (defn- duplicate-resource-anomaly [[type id]] (ba/incorrect (format "Duplicate resource `%s/%s`." type id) diff --git a/modules/db/src/blaze/db/spec.clj b/modules/db/src/blaze/db/spec.clj index d565aaf96..a04f84332 100644 --- a/modules/db/src/blaze/db/spec.clj +++ b/modules/db/src/blaze/db/spec.clj @@ -96,6 +96,11 @@ :type :fhir.resource/type :clauses (s/? (s/coll-of :blaze.db.query/search-clause :kind vector? :min-count 1)))) +(defmethod tx-op :delete-history [_] + (s/cat :op #{:delete-history} + :type :fhir.resource/type + :id :blaze.resource/id)) + (s/def :blaze.db/tx-op (s/multi-spec tx-op first)) diff --git a/modules/db/test/blaze/db/api_test.clj b/modules/db/test/blaze/db/api_test.clj index 206faf683..da465fc54 100644 --- a/modules/db/test/blaze/db/api_test.clj +++ b/modules/db/test/blaze/db/api_test.clj @@ -203,13 +203,16 @@ medication-administrations medication-administration-gen] (concat observations encounters procedures medication-administrations)))) +(defn- pull-resource [db type id] + (d/pull db (d/resource-handle db type id))) + (deftest transact-create-test (testing "one Patient" (with-system [{:blaze.db/keys [node]} config] (given @(mtu/assoc-thread-name (d/transact node [[:create {:fhir/type :fhir/Patient :id "0"}]])) [meta :thread-name] :? mtu/common-pool-thread?) - (given @(d/pull node (d/resource-handle (d/db node) "Patient" "0")) + (given @(pull-resource (d/db node) "Patient" "0") :fhir/type := :fhir/Patient :id := "0" [:meta :versionId] := #fhir/id"1" @@ -224,13 +227,13 @@ :subject #fhir/Reference{:reference "Patient/0"}}] [:create {:fhir/type :fhir/Patient :id "0"}]]] - (given @(d/pull node (d/resource-handle (d/db node) "Patient" "0")) + (given @(pull-resource (d/db node) "Patient" "0") :fhir/type := :fhir/Patient :id := "0" [:meta :versionId] := #fhir/id"1" [meta :blaze.db/op] := :create) - (given @(d/pull node (d/resource-handle (d/db node) "Observation" "0")) + (given @(pull-resource (d/db node) "Observation" "0") :fhir/type := :fhir/Observation :id := "0" [:subject :reference] := "Patient/0" @@ -263,7 +266,7 @@ {:fhir/type :fhir/Patient :id "0"} [["identifier" "111033"]]]])] (testing "the Patient was created" - (given @(d/pull node (d/resource-handle db "Patient" "0")) + (given @(pull-resource db "Patient" "0") :fhir/type := :fhir/Patient :id := "0" [:meta :versionId] := #fhir/id"1" @@ -278,7 +281,7 @@ {:fhir/type :fhir/Patient :id "1"} [["identifier" "111033"]]]])] (testing "the Patient was created" - (given @(d/pull node (d/resource-handle db "Patient" "1")) + (given @(pull-resource db "Patient" "1") :fhir/type := :fhir/Patient :id := "1" [:meta :versionId] := #fhir/id"2" @@ -345,7 +348,7 @@ [[[:put {:fhir/type :fhir/Patient :id "0"}]]] (testing "the Patient was created" - (given @(d/pull node (d/resource-handle (d/db node) "Patient" "0")) + (given @(pull-resource (d/db node) "Patient" "0") :fhir/type := :fhir/Patient :id := "0" [:meta :versionId] := #fhir/id"1" @@ -360,7 +363,7 @@ :value "2022"}}]]] (testing "the Patient was created" - (given @(d/pull node (d/resource-handle (d/db node) "Patient" "0")) + (given @(pull-resource (d/db node) "Patient" "0") :fhir/type := :fhir/Patient :id := "0" [:meta :versionId] := #fhir/id"1" @@ -378,14 +381,14 @@ [:put {:fhir/type :fhir/Patient :id "0"}]]] (testing "the Patient was created" - (given @(d/pull node (d/resource-handle (d/db node) "Patient" "0")) + (given @(pull-resource (d/db node) "Patient" "0") :fhir/type := :fhir/Patient :id := "0" [:meta :versionId] := #fhir/id"1" [meta :blaze.db/op] := :put)) (testing "the Observation was created" - (given @(d/pull node (d/resource-handle (d/db node) "Observation" "0")) + (given @(pull-resource (d/db node) "Observation" "0") :fhir/type := :fhir/Observation :id := "0" [:meta :versionId] := #fhir/id"1" @@ -397,7 +400,7 @@ [[[:put {:fhir/type :fhir/Patient :id "0" :gender #fhir/code"male"}]] [[:put {:fhir/type :fhir/Patient :id "0" :gender #fhir/code"female"}]]] - (given @(d/pull node (d/resource-handle (d/db node) "Patient" "0")) + (given @(pull-resource (d/db node) "Patient" "0") :fhir/type := :fhir/Patient :id := "0" [:meta :versionId] := #fhir/id"2" @@ -433,7 +436,7 @@ [[:put {:fhir/type :fhir/Patient :id "0" :gender #fhir/code"female"}]]] (testing "versionId is still 1" - (given @(d/pull node (d/resource-handle (d/db node) "Patient" "0")) + (given @(pull-resource (d/db node) "Patient" "0") :fhir/type := :fhir/Patient :id := "0" [:meta :versionId] := #fhir/id"1" @@ -457,27 +460,27 @@ :subject #fhir/Reference{:reference "Patient/0"}}] [:put {:fhir/type :fhir/Patient :id "0"}]]] - (given @(d/pull node (d/resource-handle (d/db node) "Patient" "0")) + (given @(pull-resource (d/db node) "Patient" "0") :fhir/type := :fhir/Patient :id := "0" [:meta :versionId] := #fhir/id"1" [meta :blaze.db/op] := :put) - (given @(d/pull node (d/resource-handle (d/db node) "Observation" "0")) + (given @(pull-resource (d/db node) "Observation" "0") :fhir/type := :fhir/Observation :id := "0" [:meta :versionId] := #fhir/id"1" [:subject :reference] := "Patient/0" [meta :blaze.db/op] := :put) - (given @(d/pull node (d/resource-handle (d/db node) "Observation" "1")) + (given @(pull-resource (d/db node) "Observation" "1") :fhir/type := :fhir/Observation :id := "1" [:meta :versionId] := #fhir/id"1" [:subject :reference] := "Patient/0" [meta :blaze.db/op] := :put) - (given @(d/pull node (d/resource-handle (d/db node) "List" "0")) + (given @(pull-resource (d/db node) "List" "0") :fhir/type := :fhir/List :id := "0" [:meta :versionId] := #fhir/id"1" @@ -794,6 +797,232 @@ ::anom/message := "Conditional delete of all Patients failed because more than 10,000 matches were found." :fhir/issue := "too-costly"))))) +(defn- pull-instance-history + ([db type id] + (d/pull-many db (d/instance-history db type id))) + ([db type id start-t] + (d/pull-many db (d/instance-history db type id start-t)))) + +(deftest transact-delete-history-test + (testing "one patient with one version" + (with-system-data [{:blaze.db/keys [node]} config] + [[[:create {:fhir/type :fhir/Patient :id "0"}]]] + + (let [db-before (d/db node) + db-after @(d/transact node [[:delete-history "Patient" "0"]])] + + (testing "nothing changed" + (testing "the patient is the same" + (is (= (d/resource-handle db-before "Patient" "0") + (d/resource-handle db-after "Patient" "0")))) + + (testing "the instance histories are the same" + (is (= (d/total-num-of-instance-changes db-before "Patient" "0") + (d/total-num-of-instance-changes db-after "Patient" "0"))) + (is (= (vec (d/instance-history db-before "Patient" "0")) + (vec (d/instance-history db-after "Patient" "0"))))) + + (testing "the type histories are the same" + (is (= (d/total-num-of-type-changes db-before "Patient") + (d/total-num-of-type-changes db-after "Patient"))) + (is (= (vec (d/type-history db-before "Patient")) + (vec (d/type-history db-after "Patient"))))) + + (testing "the system histories are the same" + (is (= (d/total-num-of-system-changes db-before) + (d/total-num-of-system-changes db-after))) + (is (= (vec (d/system-history db-before)) + (vec (d/system-history db-after))))))))) + + (testing "one patient with two versions" + (with-system-data [{:blaze.db/keys [node]} config] + [[[:create {:fhir/type :fhir/Patient :id "0" :active false}]] + [[:put {:fhir/type :fhir/Patient :id "0" :active true}]]] + + (let [db-before (d/db node) + db-after @(d/transact node [[:delete-history "Patient" "0"]])] + + (testing "the patient" + (let [patient @(pull-resource db-after "Patient" "0")] + + (testing "is active" + (given patient + :active := true)) + + (testing "the instance history contains only that patient" + (is (= 1 (d/total-num-of-instance-changes db-after "Patient" "0"))) + (is (= [patient] @(pull-instance-history db-after "Patient" "0")))))) + + (testing "the type history contains only one entry" + (is (= 1 (d/total-num-of-type-changes db-after "Patient"))) + (given @(d/pull-many node (d/type-history db-after "Patient")) + count := 1 + [0 :active] := true)) + + (testing "the system history contains only one entry" + (is (= 1 (d/total-num-of-system-changes db-after))) + (given @(d/pull-many node (d/system-history db-after)) + count := 1 + [0 :active] := true)) + + (testing "the instance history of db-before still contains two entries" + (given @(pull-instance-history db-before "Patient" "0") + count := 2 + [0 :active] := true + [1 :active] := false)) + + (testing "the type history of db-before still contains two entries" + (is (= 2 (d/total-num-of-type-changes db-before "Patient"))) + (given @(d/pull-many node (d/type-history db-before "Patient")) + count := 2 + [0 :active] := true + [1 :active] := false)) + + (testing "the system history of db-before still contains two entries" + (is (= 2 (d/total-num-of-system-changes db-before))) + (given @(d/pull-many node (d/system-history db-before)) + count := 2 + [0 :active] := true + [1 :active] := false))))) + + (testing "one deleted patient" + (with-system-data [{:blaze.db/keys [node]} config] + [[[:create {:fhir/type :fhir/Patient :id "0"}]] + [[:delete "Patient" "0"]]] + + (let [db-before (d/db node) + db-after @(d/transact node [[:delete-history "Patient" "0"]])] + + (testing "the patient is still deleted" + (given (d/resource-handle db-after "Patient" "0") + :op := :delete)) + + (testing "the instance history contains only one entry" + (given (vec (d/instance-history db-after "Patient" "0")) + count := 1 + [0 :op] := :delete)) + + (testing "the type history contains only one entry" + (is (= 1 (d/total-num-of-type-changes db-after "Patient"))) + (given (vec (d/type-history db-after "Patient")) + count := 1 + [0 :op] := :delete)) + + (testing "the system history contains only one entry" + (is (= 1 (d/total-num-of-system-changes db-after))) + (given (vec (d/system-history db-after)) + count := 1 + [0 :op] := :delete)) + + (testing "the instance history of db-before still contains two entries" + (given (vec (d/instance-history db-before "Patient" "0")) + count := 2 + [0 :op] := :delete + [1 :op] := :create)) + + (testing "the type history of db-before still contains two entries" + (is (= 2 (d/total-num-of-type-changes db-before "Patient"))) + (given (vec (d/type-history db-before "Patient")) + count := 2 + [0 :op] := :delete + [1 :op] := :create)) + + (testing "the system history of db-before still contains two entries" + (is (= 2 (d/total-num-of-system-changes db-before))) + (given (vec (d/system-history db-before)) + count := 2 + [0 :op] := :delete + [1 :op] := :create))))) + + (testing "adding a new version on top of a deleted history" + (with-system-data [{:blaze.db/keys [node]} config] + [[[:create {:fhir/type :fhir/Patient :id "0" :active false}]] + [[:put {:fhir/type :fhir/Patient :id "0" :active true}]] + [[:delete-history "Patient" "0"]]] + + (let [db-before (d/db node) + db-after @(d/transact node [[:put {:fhir/type :fhir/Patient :id "0" + :gender #fhir/code"male"}]])] + + (testing "the patient is male" + (given @(pull-resource db-after "Patient" "0") + :gender := #fhir/code"male")) + + (testing "the instance history contains two entries" + (given @(pull-instance-history db-after "Patient" "0") + count := 2 + [0 :gender] := #fhir/code"male" + [1 :active] := true)) + + (testing "the type history contains two entries" + (is (= 2 (d/total-num-of-type-changes db-after "Patient"))) + (given @(d/pull-many node (d/type-history db-after "Patient")) + count := 2 + [0 :gender] := #fhir/code"male" + [1 :active] := true)) + + (testing "the system history contains two entries" + (is (= 2 (d/total-num-of-system-changes db-after))) + (given @(d/pull-many node (d/system-history db-after)) + count := 2 + [0 :gender] := #fhir/code"male" + [1 :active] := true)) + + (testing "the instance history of db-before still contains only one entry" + (given @(pull-instance-history db-before "Patient" "0") + count := 1 + [0 :active] := true)) + + (testing "the type history of db-before still contains only one entry" + (is (= 1 (d/total-num-of-type-changes db-before "Patient"))) + (given @(d/pull-many node (d/type-history db-before "Patient")) + count := 1 + [0 :active] := true)) + + (testing "the system history of db-before still contains only one entry" + (is (= 1 (d/total-num-of-system-changes db-before))) + (given @(d/pull-many node (d/system-history db-before)) + count := 1 + [0 :active] := true)))))) + +(deftest transact-delete-history-too-many-test + (log/set-min-level! :info) + (st/unstrument) + (testing "works with up to 100,000 history entries" + (with-system-data [{:blaze.db/keys [node]} config] + (vec (for [_ (range 50000) + active [true false]] + [[:put {:fhir/type :fhir/Patient :id "0" :active active}]])) + + (let [db-before (d/db node) + db-after @(d/transact node [[:delete-history "Patient" "0"]])] + + (testing "the patient" + (let [patient @(pull-resource db-after "Patient" "0")] + + (testing "is not active" + (given patient + :active := false)) + + (testing "the instance history contains only that patient" + (is (= [patient] @(pull-instance-history db-after "Patient" "0")))))) + + (testing "the instance history of db-before still contains 100,000 entries" + (is (= 100000 (count (d/instance-history db-before "Patient" "0")))))))) + + (testing "fails on more then 100,000 history entries" + (with-system-data [{:blaze.db/keys [node]} config] + (into + [[[:put {:fhir/type :fhir/Patient :id "0" :active false}]]] + (for [_ (range 50000) + active [true false]] + [[:put {:fhir/type :fhir/Patient :id "0" :active active}]])) + + (given-failed-future (d/transact node [[:delete-history "Patient" "0"]]) + ::anom/category := ::anom/conflict + ::anom/message := "Deleting the history of `Patient/0` failed because there are more than 100,000 history entries." + :fhir/issue := "too-costly")))) + (deftest transact-test (testing "a transaction with duplicate resources fails" (testing "two puts" @@ -841,7 +1070,7 @@ :id := "1")) (testing "the first patient is still active" - (given @(d/pull node (d/resource-handle db "Patient" "0")) + (given @(pull-resource db "Patient" "0") :id := "0" :active := true))))) @@ -896,7 +1125,7 @@ {:fhir/type :fhir/Observation :id "0" :subject #fhir/Reference{:reference "Patient/0"}}]]] - (given @(d/pull node (d/resource-handle (d/db node) "Observation" "0")) + (given @(pull-resource (d/db node) "Observation" "0") :fhir/type := :fhir/Observation :id := "0" [:meta :versionId] := #fhir/id"1" @@ -1073,7 +1302,7 @@ [[[:create {:fhir/type :fhir/Patient :id "0"}]]] (testing "pull" - (given @(d/pull node (d/resource-handle (d/db node) "Patient" "0")) + (given @(pull-resource (d/db node) "Patient" "0") :fhir/type := :fhir/Patient :id := "0" [:meta :versionId] := #fhir/id"1" @@ -1093,7 +1322,7 @@ (with-system-data [{:blaze.db/keys [node]} config] [[[:put {:fhir/type :fhir/Patient :id "0"}]]] - (given @(d/pull node (d/resource-handle (d/db node) "Patient" "0")) + (given @(pull-resource (d/db node) "Patient" "0") :fhir/type := :fhir/Patient :id := "0" [:meta :versionId] := #fhir/id"1" @@ -1105,7 +1334,7 @@ [[[:put {:fhir/type :fhir/Patient :id "0"}]] [[:delete "Patient" "0"]]] - (given @(d/pull node (d/resource-handle (d/db node) "Patient" "0")) + (given @(pull-resource (d/db node) "Patient" "0") :fhir/type := :fhir/Patient :id := "0" [:meta :versionId] := #fhir/id"2" @@ -5837,7 +6066,7 @@ (is (= 1 (d/total-num-of-instance-changes (d/db node) "Patient" "0")))) (testing "contains that patient" - (given @(d/pull-many node (d/instance-history (d/db node) "Patient" "0")) + (given @(pull-instance-history (d/db node) "Patient" "0") count := 1 [0 :fhir/type] := :fhir/Patient [0 :id] := "0" @@ -5855,7 +6084,7 @@ (testing "has two history entries" (is (= 2 (d/total-num-of-instance-changes (d/db node) "Patient" "0")))) - (let [patients @(d/pull-many node (d/instance-history (d/db node) "Patient" "0"))] + (let [patients @(pull-instance-history (d/db node) "Patient" "0")] (is (= 2 (count patients))) (testing "the first history entry is the patient marked as deleted" @@ -5882,13 +6111,13 @@ (is (= 2 (d/total-num-of-instance-changes (d/db node) "Patient" "0")))) (testing "contains both versions in reverse transaction order" - (given @(d/pull-many node (d/instance-history (d/db node) "Patient" "0")) + (given @(pull-instance-history (d/db node) "Patient" "0") count := 2 [0 :active] := false [1 :active] := true)) (testing "it is possible to start with the older transaction" - (given @(d/pull-many node (d/instance-history (d/db node) "Patient" "0" 1)) + (given @(pull-instance-history (d/db node) "Patient" "0" 1) count := 1 [0 :active] := true)) @@ -5927,7 +6156,7 @@ (is (= 1 (d/total-num-of-instance-changes db "Patient" "0")))) (testing "contains still the original patient" - (given @(d/pull-many node (d/instance-history db "Patient" "0")) + (given @(pull-instance-history db "Patient" "0") count := 1 [0 :fhir/type] := :fhir/Patient [0 :id] := "0" diff --git a/modules/db/test/blaze/db/impl/index/resource_as_of_spec.clj b/modules/db/test/blaze/db/impl/index/resource_as_of_spec.clj index 4eaf1a3c8..82089ad85 100644 --- a/modules/db/test/blaze/db/impl/index/resource_as_of_spec.clj +++ b/modules/db/test/blaze/db/impl/index/resource_as_of_spec.clj @@ -34,6 +34,7 @@ :args (s/cat :snapshot ::kv/snapshot :tid :blaze.db/tid :id :blaze.db/id-byte-string + :t :blaze.db/t :start-t :blaze.db/t) :ret (cs/coll-of :blaze.db/resource-handle)) @@ -56,11 +57,3 @@ :id-extractor (s/? fn?) :matcher (s/? fn?)) :ret fn?) - -(s/fdef rao/num-of-instance-changes - :args (s/cat :snapshot ::kv/snapshot - :tid :blaze.db/tid - :id :blaze.db/id-byte-string - :start-t :blaze.db/t - :end-t :blaze.db/t) - :ret nat-int?) diff --git a/modules/db/test/blaze/db/impl/index/resource_handle_spec.clj b/modules/db/test/blaze/db/impl/index/resource_handle_spec.clj index dc37469ce..bb7a83048 100644 --- a/modules/db/test/blaze/db/impl/index/resource_handle_spec.clj +++ b/modules/db/test/blaze/db/impl/index/resource_handle_spec.clj @@ -48,6 +48,10 @@ :args (s/cat :rh rh/resource-handle?) :ret :blaze.db/op) +(s/fdef rh/purged-at + :args (s/cat :rh rh/resource-handle?) + :ret :blaze.db/t) + (s/fdef rh/reference :args (s/cat :rh rh/resource-handle?) :ret :blaze.fhir/literal-ref) diff --git a/modules/db/test/blaze/db/impl/index/resource_handle_test.clj b/modules/db/test/blaze/db/impl/index/resource_handle_test.clj index 33e302a48..54155cc47 100644 --- a/modules/db/test/blaze/db/impl/index/resource_handle_test.clj +++ b/modules/db/test/blaze/db/impl/index/resource_handle_test.clj @@ -27,7 +27,9 @@ ([tid id t hash num-changes] (resource-handle tid id t hash num-changes :create)) ([tid id t hash num-changes op] - (rh/->ResourceHandle tid id t hash num-changes op))) + (resource-handle tid id t hash num-changes op Long/MAX_VALUE)) + ([tid id t hash num-changes op purged-at] + (rh/->ResourceHandle tid id t hash num-changes op purged-at))) (deftest state->num-changes-test (are [state num-changes] (= num-changes @@ -77,6 +79,12 @@ (let [rh (resource-handle 0 "foo" 0 hash 0 op)] (= op (:op rh) (rh/op rh) (apply rh/op [rh])))))) +(deftest purged-at-test + (satisfies-prop 10 + (prop/for-all [purged-at (s/gen :blaze.db/t)] + (let [rh (resource-handle 0 "foo" 0 hash 0 :create purged-at)] + (= purged-at (:purged-at rh) (rh/purged-at rh) (apply rh/purged-at [rh])))))) + (deftest reference-test (satisfies-prop 100 (prop/for-all [id (s/gen :blaze.resource/id)] diff --git a/modules/db/test/blaze/db/impl/index/rts_as_of_spec.clj b/modules/db/test/blaze/db/impl/index/rts_as_of_spec.clj index 50ddb8a02..067a875d2 100644 --- a/modules/db/test/blaze/db/impl/index/rts_as_of_spec.clj +++ b/modules/db/test/blaze/db/impl/index/rts_as_of_spec.clj @@ -14,5 +14,6 @@ :t :blaze.db/t :hash :blaze.resource/hash :num-changes nat-int? - :op keyword?) + :op keyword? + :purged-at (s/? :blaze.db/t)) :ret (s/coll-of :blaze.db.kv/put-entry)) diff --git a/modules/db/test/blaze/db/impl/index/rts_as_of_test_util.clj b/modules/db/test/blaze/db/impl/index/rts_as_of_test_util.clj index 732e97882..8f7f267c0 100644 --- a/modules/db/test/blaze/db/impl/index/rts_as_of_test_util.clj +++ b/modules/db/test/blaze/db/impl/index/rts_as_of_test_util.clj @@ -10,6 +10,9 @@ (let [buf (bb/wrap byte-array) hash (hash/from-byte-buffer! buf) state (bb/get-long! buf)] - {:hash hash - :num-changes (rh/state->num-changes state) - :op (rh/state->op state)})) + (cond-> + {:hash hash + :num-changes (rh/state->num-changes state) + :op (rh/state->op state)} + (<= 8 (bb/remaining buf)) + (assoc :purged-at (bb/get-long! buf))))) diff --git a/modules/db/test/blaze/db/impl/index/system_as_of_spec.clj b/modules/db/test/blaze/db/impl/index/system_as_of_spec.clj index aa0769ece..7e63b795c 100644 --- a/modules/db/test/blaze/db/impl/index/system_as_of_spec.clj +++ b/modules/db/test/blaze/db/impl/index/system_as_of_spec.clj @@ -15,6 +15,7 @@ (s/fdef sao/system-history :args (s/cat :snapshot :blaze.db.kv/snapshot + :t :blaze.db/t :start-t :blaze.db/t :start-tid (s/nilable :blaze.db/tid) :start-id (s/nilable :blaze.db/id-byte-string)) diff --git a/modules/db/test/blaze/db/impl/index/type_as_of_spec.clj b/modules/db/test/blaze/db/impl/index/type_as_of_spec.clj index 915bb53b6..6ac4dc358 100644 --- a/modules/db/test/blaze/db/impl/index/type_as_of_spec.clj +++ b/modules/db/test/blaze/db/impl/index/type_as_of_spec.clj @@ -15,6 +15,7 @@ (s/fdef tao/type-history :args (s/cat :snapshot :blaze.db.kv/snapshot :tid :blaze.db/tid + :t :blaze.db/t :start-t :blaze.db/t :start-id (s/nilable :blaze.db/id-byte-string)) :ret (cs/coll-of :blaze.db/resource-handle)) diff --git a/modules/db/test/blaze/db/node/tx_indexer/verify_test.clj b/modules/db/test/blaze/db/node/tx_indexer/verify_test.clj index 487a73f31..bb4f4e097 100644 --- a/modules/db/test/blaze/db/node/tx_indexer/verify_test.clj +++ b/modules/db/test/blaze/db/node/tx_indexer/verify_test.clj @@ -1,5 +1,6 @@ (ns blaze.db.node.tx-indexer.verify-test (:require + [blaze.anomaly :as ba] [blaze.byte-string :as bs] [blaze.db.api :as d] [blaze.db.impl.index.patient-last-change-test-util :as plc-tu] @@ -23,9 +24,12 @@ [blaze.fhir.spec.type] [blaze.log] [blaze.module.test-util :refer [with-system]] - [blaze.test-util :as tu] + [blaze.test-util :as tu :refer [satisfies-prop]] + [clojure.spec.alpha :as s] + [clojure.spec.gen.alpha :as sg] [clojure.spec.test.alpha :as st] [clojure.test :as test :refer [deftest is testing]] + [clojure.test.check.properties :as prop] [cognitect.anomalies :as anom] [juxt.iota :refer [given]] [taoensso.timbre :as log])) @@ -49,6 +53,20 @@ :patient #fhir/Reference{:reference "Patient/0"}}) (deftest verify-tx-cmds-test + (testing "two commands with the same identity aren't allowed" + (let [hash (hash/generate patient-0) + op-gen (sg/such-that (complement #{"conditional-delete"}) + (s/gen :blaze.db.tx-cmd/op)) + cmd (fn [op] {:op op :type "Patient" :id "0" :hash hash})] + (with-system [{:blaze.db/keys [node]} config] + + (satisfies-prop 100 + (prop/for-all [op-1 op-gen + op-2 op-gen] + (ba/conflict? + (verify/verify-tx-cmds search-param-registry (d/db node) 1 + [(cmd op-1) (cmd op-2)]))))))) + (testing "adding one Patient to an empty store" (let [hash (hash/generate patient-0)] (doseq [op [:create :put] @@ -867,3 +885,107 @@ [7 0] := :system-stats-index [7 1 ss-tu/decode-key] := {:t 2} [7 2 ss-tu/decode-val] := {:total 0 :num-changes 4}))))))) + +(deftest verify-delete-history-test + (testing "empty database" + (with-system [{:blaze.db/keys [node]} config] + + (is (empty? (verify/verify-tx-cmds + search-param-registry + (d/db node) 1 + [{:op "delete-history" :type "Patient" :id "0"}]))))) + + (testing "one patient" + (testing "with one version" + (with-system-data [{:blaze.db/keys [node]} config] + [[[:create {:fhir/type :fhir/Patient :id "0"}]]] + + (is (empty? (verify/verify-tx-cmds + search-param-registry + (d/db node) 3 + [{:op "delete-history" :type "Patient" :id "0"}]))))) + + (testing "with two versions" + (let [patient-v1 {:fhir/type :fhir/Patient :id "0" :active false} + patient-v2 {:fhir/type :fhir/Patient :id "0" :active true} + hash-v1 (hash/generate patient-v1)] + (with-system-data [{:blaze.db/keys [node]} config] + [[[:create patient-v1]] + [[:put patient-v2]]] + + (given (verify/verify-tx-cmds + search-param-registry + (d/db node) 3 + [{:op "delete-history" :type "Patient" :id "0"}]) + + count := 5 + + [0 0] := :resource-as-of-index + [0 1 rao-tu/decode-key] := {:type "Patient" :id "0" :t 1} + [0 2 rts-tu/decode-val] := {:hash hash-v1 :num-changes 1 :op :create :purged-at 3} + + [1 0] := :type-as-of-index + [1 1 tao-tu/decode-key] := {:type "Patient" :t 1 :id "0"} + [1 2 rts-tu/decode-val] := {:hash hash-v1 :num-changes 1 :op :create :purged-at 3} + + [2 0] := :system-as-of-index + [2 1 sao-tu/decode-key] := {:t 1 :type "Patient" :id "0"} + [2 2 rts-tu/decode-val] := {:hash hash-v1 :num-changes 1 :op :create :purged-at 3} + + [3 0] := :type-stats-index + [3 1 ts-tu/decode-key] := {:type "Patient" :t 3} + [3 2 ts-tu/decode-val] := {:total 1 :num-changes 1} + + [4 0] := :system-stats-index + [4 1 ss-tu/decode-key] := {:t 3} + [4 2 ss-tu/decode-val] := {:total 1 :num-changes 1})))) + + (testing "with three versions" + (let [patient-v1 {:fhir/type :fhir/Patient :id "0" :active false} + patient-v2 {:fhir/type :fhir/Patient :id "0" :active true} + patient-v3 {:fhir/type :fhir/Patient :id "0" :active false} + hash-v1 (hash/generate patient-v1) + hash-v2 (hash/generate patient-v2)] + (with-system-data [{:blaze.db/keys [node]} config] + [[[:create patient-v1]] + [[:put patient-v2]] + [[:put patient-v3]]] + + (given (verify/verify-tx-cmds + search-param-registry + (d/db node) 4 + [{:op "delete-history" :type "Patient" :id "0"}]) + + count := 8 + + [0 0] := :resource-as-of-index + [0 1 rao-tu/decode-key] := {:type "Patient" :id "0" :t 2} + [0 2 rts-tu/decode-val] := {:hash hash-v2 :num-changes 2 :op :put :purged-at 4} + + [1 0] := :type-as-of-index + [1 1 tao-tu/decode-key] := {:type "Patient" :t 2 :id "0"} + [1 2 rts-tu/decode-val] := {:hash hash-v2 :num-changes 2 :op :put :purged-at 4} + + [2 0] := :system-as-of-index + [2 1 sao-tu/decode-key] := {:t 2 :type "Patient" :id "0"} + [2 2 rts-tu/decode-val] := {:hash hash-v2 :num-changes 2 :op :put :purged-at 4} + + [3 0] := :resource-as-of-index + [3 1 rao-tu/decode-key] := {:type "Patient" :id "0" :t 1} + [3 2 rts-tu/decode-val] := {:hash hash-v1 :num-changes 1 :op :create :purged-at 4} + + [4 0] := :type-as-of-index + [4 1 tao-tu/decode-key] := {:type "Patient" :t 1 :id "0"} + [4 2 rts-tu/decode-val] := {:hash hash-v1 :num-changes 1 :op :create :purged-at 4} + + [5 0] := :system-as-of-index + [5 1 sao-tu/decode-key] := {:t 1 :type "Patient" :id "0"} + [5 2 rts-tu/decode-val] := {:hash hash-v1 :num-changes 1 :op :create :purged-at 4} + + [6 0] := :type-stats-index + [6 1 ts-tu/decode-key] := {:type "Patient" :t 4} + [6 2 ts-tu/decode-val] := {:total 1 :num-changes 1} + + [7 0] := :system-stats-index + [7 1 ss-tu/decode-key] := {:t 4} + [7 2 ss-tu/decode-val] := {:total 1 :num-changes 1})))))) diff --git a/modules/http-client/test/blaze/http_client_test.clj b/modules/http-client/test/blaze/http_client_test.clj index 415a6e200..61341dd28 100644 --- a/modules/http-client/test/blaze/http_client_test.clj +++ b/modules/http-client/test/blaze/http_client_test.clj @@ -20,7 +20,7 @@ (set! *warn-on-reflection* true) (st/instrument) -(log/set-level! :trace) +(log/set-min-level! :trace) (test/use-fixtures :each tu/fixture) diff --git a/modules/interaction/.clj-kondo/config.edn b/modules/interaction/.clj-kondo/config.edn index 522e04b8c..a70b2e5a7 100644 --- a/modules/interaction/.clj-kondo/config.edn +++ b/modules/interaction/.clj-kondo/config.edn @@ -9,13 +9,14 @@ :lint-as {blaze.interaction.create-test/with-handler clojure.core/fn blaze.interaction.delete-test/with-handler clojure.core/fn + blaze.interaction.delete-history-test/with-handler clojure.core/fn blaze.interaction.conditional-delete-type-test/with-handler clojure.core/fn blaze.interaction.conditional-delete-type-test/with-handler-allow-multiple clojure.core/fn blaze.interaction.history.instance-test/with-handler clojure.core/fn blaze.interaction.history.system-test/with-handler clojure.core/fn blaze.interaction.history.type-test/with-handler clojure.core/fn blaze.interaction.read-test/with-handler clojure.core/fn - blaze.interaction.read-test/with-vread-handler clojure.core/fn + blaze.interaction.vread-test/with-handler clojure.core/fn blaze.interaction.search-compartment-test/with-handler clojure.core/fn blaze.interaction.search-system-test/with-handler clojure.core/fn blaze.interaction.search-type-test/with-handler clojure.core/fn diff --git a/modules/interaction/src/blaze/interaction/delete_history.clj b/modules/interaction/src/blaze/interaction/delete_history.clj new file mode 100644 index 000000000..802f31a40 --- /dev/null +++ b/modules/interaction/src/blaze/interaction/delete_history.clj @@ -0,0 +1,24 @@ +(ns blaze.interaction.delete-history + "FHIR delete-history interaction. + + https://build.fhir.org/http.html#delete-history" + (:require + [blaze.async.comp :refer [do-sync]] + [blaze.db.api :as d] + [blaze.db.spec] + [blaze.module :as m] + [clojure.spec.alpha :as s] + [integrant.core :as ig] + [reitit.core :as reitit] + [ring.util.response :as ring] + [taoensso.timbre :as log])) + +(defmethod m/pre-init-spec :blaze.interaction/delete-history [_] + (s/keys :req-un [:blaze.db/node])) + +(defmethod ig/init-key :blaze.interaction/delete-history [_ {:keys [node]}] + (log/info "Init FHIR delete-history interaction handler") + (fn [{{{:fhir.resource/keys [type]} :data} ::reitit/match + {:keys [id]} :path-params}] + (do-sync [_ (d/transact node [[:delete-history type id]])] + (ring/status 204)))) diff --git a/modules/interaction/src/blaze/interaction/read.clj b/modules/interaction/src/blaze/interaction/read.clj index b23d0e117..850e733a0 100644 --- a/modules/interaction/src/blaze/interaction/read.clj +++ b/modules/interaction/src/blaze/interaction/read.clj @@ -3,40 +3,20 @@ https://www.hl7.org/fhir/http.html#read" (:require - [blaze.anomaly :as ba] - [blaze.async.comp :as ac :refer [do-sync]] + [blaze.async.comp :refer [do-sync]] [blaze.db.spec] [blaze.handler.fhir.util :as fhir-util] + [blaze.interaction.util :as iu] [integrant.core :as ig] [reitit.core :as reitit] - [ring.util.response :as ring] [taoensso.timbre :as log])) -(defn- response [resource] - (let [{:blaze.db/keys [tx]} (meta resource)] - (-> (ring/response resource) - (ring/header "Last-Modified" (fhir-util/last-modified tx)) - (ring/header "ETag" (fhir-util/etag tx))))) - (def ^:private handler (fn [{{{:fhir.resource/keys [type]} :data} ::reitit/match {:keys [id]} :path-params :blaze/keys [db]}] (do-sync [resource (fhir-util/pull db type id)] - (response resource)))) - -(defn- wrap-invalid-id [handler] - (fn [{{:keys [id]} :path-params :as request}] - (cond - (not (re-matches #"[A-Za-z0-9\-\.]{1,64}" id)) - (ac/completed-future - (ba/incorrect - (format "Resource id `%s` is invalid." id) - :fhir/issue "value" - :fhir/operation-outcome "MSG_ID_INVALID")) - - :else - (handler request)))) + (iu/response resource)))) (defmethod ig/init-key :blaze.interaction/read [_ _] (log/info "Init FHIR read interaction handler") - (wrap-invalid-id handler)) + (iu/wrap-invalid-id handler)) diff --git a/modules/interaction/src/blaze/interaction/util.clj b/modules/interaction/src/blaze/interaction/util.clj index 028936cb7..b769fa809 100644 --- a/modules/interaction/src/blaze/interaction/util.clj +++ b/modules/interaction/src/blaze/interaction/util.clj @@ -1,12 +1,15 @@ (ns blaze.interaction.util (:require [blaze.anomaly :as ba :refer [when-ok]] + [blaze.async.comp :as ac] [blaze.db.api :as d] [blaze.fhir.hash :as hash] [blaze.fhir.spec.type :as type] + [blaze.handler.fhir.util :as fhir-util] [blaze.luid :as luid] [blaze.util :as u] - [clojure.string :as str])) + [clojure.string :as str] + [ring.util.response :as ring])) (defn etag->t [etag] (let [[_ t] (re-find #"W/\"(\d+)\"" etag)] @@ -124,3 +127,22 @@ {:arglists '([tx-op])} [[op]] (identical? :keep op)) + +(defn wrap-invalid-id [handler] + (fn [{{:keys [id]} :path-params :as request}] + (cond + (not (re-matches #"[A-Za-z0-9\-\.]{1,64}" id)) + (ac/completed-future + (ba/incorrect + (format "Resource id `%s` is invalid." id) + :fhir/issue "value" + :fhir/operation-outcome "MSG_ID_INVALID")) + + :else + (handler request)))) + +(defn response [resource] + (let [{:blaze.db/keys [tx]} (meta resource)] + (-> (ring/response resource) + (ring/header "Last-Modified" (fhir-util/last-modified tx)) + (ring/header "ETag" (fhir-util/etag tx))))) diff --git a/modules/interaction/src/blaze/interaction/vread.clj b/modules/interaction/src/blaze/interaction/vread.clj new file mode 100644 index 000000000..e805cb6ca --- /dev/null +++ b/modules/interaction/src/blaze/interaction/vread.clj @@ -0,0 +1,38 @@ +(ns blaze.interaction.vread + "FHIR read interaction. + + https://www.hl7.org/fhir/http.html#read" + (:require + [blaze.anomaly :as ba] + [blaze.async.comp :as ac :refer [do-sync]] + [blaze.db.api :as d] + [blaze.db.spec] + [blaze.handler.fhir.util :as fhir-util] + [blaze.interaction.util :as iu] + [integrant.core :as ig] + [reitit.core :as reitit] + [taoensso.timbre :as log])) + +(defn- not-found-anom + ([type id] + (ba/not-found + (format "Resource `%s/%s` with the given version was not found." type id) + :http/headers [["Cache-Control" "no-cache"]])) + ([type id t] + (ba/not-found + (format "Resource `%s/%s` with version `%d` was not found." type id t) + :http/headers [["Cache-Control" "no-cache"]]))) + +(def ^:private handler + (fn [{{{:fhir.resource/keys [type]} :data} ::reitit/match + {:keys [id vid]} :path-params :blaze/keys [db]}] + (if-let [t (fhir-util/parse-nat-long vid)] + (if (<= t (d/t db)) + (do-sync [resource (fhir-util/pull-historic db type id t)] + (iu/response resource)) + (ac/completed-future (not-found-anom type id t))) + (ac/completed-future (not-found-anom type id))))) + +(defmethod ig/init-key :blaze.interaction/vread [_ _] + (log/info "Init FHIR read interaction handler") + (iu/wrap-invalid-id handler)) diff --git a/modules/interaction/test/blaze/interaction/conditional_delete_type_test.clj b/modules/interaction/test/blaze/interaction/conditional_delete_type_test.clj index 843f21a8d..d303aa1c7 100644 --- a/modules/interaction/test/blaze/interaction/conditional_delete_type_test.clj +++ b/modules/interaction/test/blaze/interaction/conditional_delete_type_test.clj @@ -1,5 +1,5 @@ (ns blaze.interaction.conditional-delete-type-test - "Specifications relevant for the FHIR update interaction: + "Specifications relevant for the FHIR conditional delete interaction: https://www.hl7.org/fhir/http.html#cdelete" (:require diff --git a/modules/interaction/test/blaze/interaction/delete_history_test.clj b/modules/interaction/test/blaze/interaction/delete_history_test.clj new file mode 100644 index 000000000..3e4932bb5 --- /dev/null +++ b/modules/interaction/test/blaze/interaction/delete_history_test.clj @@ -0,0 +1,79 @@ +(ns blaze.interaction.delete-history-test + "Specifications relevant for the FHIR delete-history interaction: + + https://build.fhir.org/http.html#delete-history" + (:require + [blaze.db.api-stub :as api-stub :refer [with-system-data]] + [blaze.db.node :refer [node?]] + [blaze.interaction.delete-history] + [blaze.log] + [blaze.test-util :as tu :refer [given-thrown]] + [clojure.spec.alpha :as s] + [clojure.spec.test.alpha :as st] + [clojure.test :as test :refer [deftest is testing]] + [integrant.core :as ig] + [reitit.core :as reitit] + [taoensso.timbre :as log])) + +(st/instrument) +(log/set-min-level! :trace) + +(test/use-fixtures :each tu/fixture) + +(deftest init-test + (testing "nil config" + (given-thrown (ig/init {:blaze.interaction/delete-history nil}) + :key := :blaze.interaction/delete-history + :reason := ::ig/build-failed-spec + [:cause-data ::s/problems 0 :pred] := `map?)) + + (testing "missing config" + (given-thrown (ig/init {:blaze.interaction/delete-history {}}) + :key := :blaze.interaction/delete-history + :reason := ::ig/build-failed-spec + [:cause-data ::s/problems 0 :pred] := `(fn ~'[%] (contains? ~'% :node)))) + + (testing "invalid node" + (given-thrown (ig/init {:blaze.interaction/delete-history {:node ::invalid}}) + :key := :blaze.interaction/delete-history + :reason := ::ig/build-failed-spec + [:cause-data ::s/problems 0 :pred] := `node? + [:cause-data ::s/problems 0 :val] := ::invalid))) + +(def config + (assoc api-stub/mem-node-config + :blaze.interaction/delete-history + {:node (ig/ref :blaze.db/node)})) + +(defmacro with-handler [[handler-binding] & more] + (let [[txs body] (api-stub/extract-txs-body more)] + `(with-system-data [{handler# :blaze.interaction/delete-history} config] + ~txs + (let [~handler-binding handler#] + ~@body)))) + +(deftest handler-test + (testing "Returns No Content on non-existing resource" + (with-handler [handler] + (let [{:keys [status body]} + @(handler + {:path-params {:id "0"} + ::reitit/match {:data {:fhir.resource/type "Patient"}}})] + + (is (= 204 status)) + + (is (nil? body))))) + + (testing "Returns No Content on successful history deletion" + (with-handler [handler] + [[[:put {:fhir/type :fhir/Patient :id "0" :active false}]] + [[:put {:fhir/type :fhir/Patient :id "0" :active true}]]] + + (let [{:keys [status body]} + @(handler + {:path-params {:id "0"} + ::reitit/match {:data {:fhir.resource/type "Patient"}}})] + + (is (= 204 status)) + + (is (nil? body)))))) diff --git a/modules/interaction/test/blaze/interaction/delete_test.clj b/modules/interaction/test/blaze/interaction/delete_test.clj index 80452ee88..e030ac0dd 100644 --- a/modules/interaction/test/blaze/interaction/delete_test.clj +++ b/modules/interaction/test/blaze/interaction/delete_test.clj @@ -1,5 +1,5 @@ (ns blaze.interaction.delete-test - "Specifications relevant for the FHIR update interaction: + "Specifications relevant for the FHIR delete interaction: https://www.hl7.org/fhir/http.html#delete" (:require @@ -33,7 +33,7 @@ :reason := ::ig/build-failed-spec [:cause-data ::s/problems 0 :pred] := `(fn ~'[%] (contains? ~'% :node)))) - (testing "invalid executor" + (testing "invalid node" (given-thrown (ig/init {:blaze.interaction/delete {:node ::invalid}}) :key := :blaze.interaction/delete :reason := ::ig/build-failed-spec @@ -43,9 +43,7 @@ (def config (assoc api-stub/mem-node-config :blaze.interaction/delete - {:node (ig/ref :blaze.db/node) - :executor (ig/ref :blaze.test/executor)} - :blaze.test/executor {})) + {:node (ig/ref :blaze.db/node)})) (defmacro with-handler [[handler-binding] & more] (let [[txs body] (api-stub/extract-txs-body more)] diff --git a/modules/interaction/test/blaze/interaction/history/instance_test.clj b/modules/interaction/test/blaze/interaction/history/instance_test.clj index 1a825f18d..6e83724af 100644 --- a/modules/interaction/test/blaze/interaction/history/instance_test.clj +++ b/modules/interaction/test/blaze/interaction/history/instance_test.clj @@ -314,6 +314,9 @@ {:path-params {:id "0"} :params {"_count" "1"}})] + (testing "the total count is 2" + (is (= #fhir/unsignedInt 2 (:total body)))) + (testing "has a self link" (is (= (str base-url context-path "/Patient/0/_history?_count=1") (link-url body "self")))) diff --git a/modules/interaction/test/blaze/interaction/read_test.clj b/modules/interaction/test/blaze/interaction/read_test.clj index 343a1f855..464496484 100644 --- a/modules/interaction/test/blaze/interaction/read_test.clj +++ b/modules/interaction/test/blaze/interaction/read_test.clj @@ -12,7 +12,7 @@ [blaze.db.spec] [blaze.interaction.read] [blaze.interaction.test-util :refer [wrap-error]] - [blaze.middleware.fhir.db :refer [wrap-db wrap-versioned-instance-db]] + [blaze.middleware.fhir.db :refer [wrap-db]] [blaze.middleware.fhir.db-spec] [blaze.test-util :as tu] [clojure.spec.test.alpha :as st] @@ -50,16 +50,6 @@ wrap-error)] ~@body)))) -(defmacro with-vread-handler [[handler-binding] & more] - (let [[txs body] (api-stub/extract-txs-body more)] - `(with-system-data [{node# :blaze.db/node - handler# :blaze.interaction/read} config] - ~txs - (let [~handler-binding (-> handler# wrap-defaults - (wrap-versioned-instance-db node# 100) - wrap-error)] - ~@body)))) - (deftest handler-test (testing "returns Not-Found on non-existing resource" (with-handler [handler] @@ -89,21 +79,6 @@ [:issue 0 :details :coding 0 :code] := #fhir/code"MSG_ID_INVALID" [:issue 0 :diagnostics] := "Resource id `A_B` is invalid.")))) - (testing "returns Bad-Request on invalid version id" - (with-vread-handler [handler] - (let [{:keys [status body]} - @(handler {:path-params {:id "0" :vid "a"}})] - - (is (= 400 status)) - - (given body - :fhir/type := :fhir/OperationOutcome - [:issue 0 :severity] := #fhir/code"error" - [:issue 0 :code] := #fhir/code"value" - [:issue 0 :details :coding 0 :system] := operation-outcome - [:issue 0 :details :coding 0 :code] := #fhir/code"MSG_ID_INVALID" - [:issue 0 :diagnostics] := "Resource versionId `a` is invalid.")))) - (testing "returns Gone on deleted resource" (with-handler [handler] [[[:put {:fhir/type :fhir/Patient :id "0"}]] @@ -151,29 +126,6 @@ (is (= 200 status)) - (testing "Transaction time in Last-Modified header" - (is (= "Thu, 1 Jan 1970 00:00:00 GMT" (get headers "Last-Modified")))) - - (testing "Version in ETag header" - ;; 1 is the T of the transaction of the resource update - (is (= "W/\"1\"" (get headers "ETag")))) - - (given body - :fhir/type := :fhir/Patient - :id := "0" - [:meta :versionId] := #fhir/id"1" - [:meta :lastUpdated] := Instant/EPOCH)))) - - (testing "returns existing resource on versioned read even if it is currently deleted" - (with-vread-handler [handler] - [[[:put {:fhir/type :fhir/Patient :id "0"}]] - [[:delete "Patient" "0"]]] - - (let [{:keys [status headers body]} - @(handler {:path-params {:id "0" :vid "1"}})] - - (is (= 200 status)) - (testing "Transaction time in Last-Modified header" (is (= "Thu, 1 Jan 1970 00:00:00 GMT" (get headers "Last-Modified")))) diff --git a/modules/interaction/test/blaze/interaction/search_type_test.clj b/modules/interaction/test/blaze/interaction/search_type_test.clj index 48e5b2b77..7d045a619 100644 --- a/modules/interaction/test/blaze/interaction/search_type_test.clj +++ b/modules/interaction/test/blaze/interaction/search_type_test.clj @@ -835,7 +835,7 @@ @(handler {:params {"active" "true" "_count" "1"}})] - (testing "their is no total count because we have clauses and we have + (testing "there is no total count because we have clauses and we have more hits than page-size" (is (nil? (:total body)))) @@ -882,7 +882,7 @@ {::reitit/match patient-search-match :params {"active" "true" "_count" "1"}})] - (testing "their is no total count because we have clauses and we have + (testing "there is no total count because we have clauses and we have more hits than page-size" (is (nil? (:total body)))) @@ -929,7 +929,7 @@ {::reitit/match patient-page-match :path-params (page-path-params page-id-cipher {"__token" "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB" "_count" "1" "__t" "1" "__page-id" "2"})})] - (testing "their is no total count because we have clauses and we have + (testing "there is no total count because we have clauses and we have more hits than page-size" (is (nil? (:total body)))) diff --git a/modules/interaction/test/blaze/interaction/vread_test.clj b/modules/interaction/test/blaze/interaction/vread_test.clj new file mode 100644 index 000000000..91fa36e2a --- /dev/null +++ b/modules/interaction/test/blaze/interaction/vread_test.clj @@ -0,0 +1,194 @@ +(ns blaze.interaction.vread-test + "Specifications relevant for the FHIR read interaction: + + https://www.hl7.org/fhir/http.html#read + https://www.hl7.org/fhir/operationoutcome.html + https://www.hl7.org/fhir/http.html#ops" + (:require + [blaze.anomaly-spec] + [blaze.db.api-stub :as api-stub :refer [with-system-data]] + [blaze.db.spec] + [blaze.interaction.test-util :refer [wrap-error]] + [blaze.interaction.vread] + [blaze.middleware.fhir.db :refer [wrap-db]] + [blaze.middleware.fhir.db-spec] + [blaze.test-util :as tu] + [clojure.spec.test.alpha :as st] + [clojure.test :as test :refer [deftest is testing]] + [juxt.iota :refer [given]] + [reitit.core :as reitit] + [taoensso.timbre :as log]) + (:import + [java.time Instant])) + +(st/instrument) +(log/set-min-level! :trace) + +(test/use-fixtures :each tu/fixture) + +(def config + (assoc api-stub/mem-node-config :blaze.interaction/vread {})) + +(def match + (reitit/map->Match {:data {:fhir.resource/type "Patient"}})) + +(def operation-outcome + #fhir/uri"http://terminology.hl7.org/CodeSystem/operation-outcome") + +(defn wrap-defaults [handler] + (fn [request] + (handler (assoc request ::reitit/match match)))) + +(defmacro with-handler [[handler-binding] & more] + (let [[txs body] (api-stub/extract-txs-body more)] + `(with-system-data [{node# :blaze.db/node + handler# :blaze.interaction/vread} config] + ~txs + (let [~handler-binding (-> handler# wrap-defaults (wrap-db node# 100) + wrap-error)] + ~@body)))) + +(deftest handler-test + (with-handler [handler] + [[[:put {:fhir/type :fhir/Patient :id "0"}]] + [[:delete "Patient" "0"]]] + + (testing "initial version" + (let [{:keys [status headers body]} + @(handler {:path-params {:id "0" :vid "1"}})] + + (is (= 200 status)) + + (testing "Transaction time in Last-Modified header" + (is (= "Thu, 1 Jan 1970 00:00:00 GMT" (get headers "Last-Modified")))) + + (testing "Version in ETag header" + ;; 1 is the T of the transaction of the resource update + (is (= "W/\"1\"" (get headers "ETag")))) + + (given body + :fhir/type := :fhir/Patient + :id := "0" + [:meta :versionId] := #fhir/id"1" + [:meta :lastUpdated] := Instant/EPOCH))) + + (testing "deleted version" + (let [{:keys [status headers body]} + @(handler {:path-params {:id "0" :vid "2"}})] + + (is (= 410 status)) + + (testing "Transaction time in Last-Modified header" + (is (= "Thu, 1 Jan 1970 00:00:00 GMT" (get headers "Last-Modified")))) + + (testing "Version in ETag header" + ;; 2 is the T of the transaction of the resource deletion + (is (= "W/\"2\"" (get headers "ETag")))) + + (given body + :fhir/type := :fhir/OperationOutcome + [:issue 0 :severity] := #fhir/code"error" + [:issue 0 :code] := #fhir/code"deleted" + [:issue 0 :diagnostics] := "Resource `Patient/0` was deleted in version `2`."))) + + (testing "non existing version" + (let [{:keys [status headers body]} + @(handler {:path-params {:id "0" :vid "3"}})] + + (is (= 404 status)) + + (testing "has no Last-Modified header" + (is (nil? (get headers "Last-Modified")))) + + (testing "has no ETag header" + (is (nil? (get headers "ETag")))) + + (testing "disallows caching" + (is (= "no-cache" (get headers "Cache-Control")))) + + (given body + :fhir/type := :fhir/OperationOutcome + [:issue 0 :severity] := #fhir/code"error" + [:issue 0 :code] := #fhir/code"not-found" + [:issue 0 :diagnostics] := "Resource `Patient/0` with version `3` was not found."))) + + (testing "invalid version" + (let [{:keys [status headers body]} + @(handler {:path-params {:id "0" :vid "a"}})] + + (is (= 404 status)) + + (testing "has no Last-Modified header" + (is (nil? (get headers "Last-Modified")))) + + (testing "has no ETag header" + (is (nil? (get headers "ETag")))) + + (testing "disallows caching" + (is (= "no-cache" (get headers "Cache-Control")))) + + (given body + :fhir/type := :fhir/OperationOutcome + [:issue 0 :severity] := #fhir/code"error" + [:issue 0 :code] := #fhir/code"not-found" + [:issue 0 :diagnostics] := "Resource `Patient/0` with the given version was not found.")))) + + (testing "with deleted history" + (with-handler [handler] + [[[:put {:fhir/type :fhir/Patient :id "0" :active false}]] + [[:put {:fhir/type :fhir/Patient :id "0" :active true}]] + [[:delete-history "Patient" "0"]]] + + (testing "initial version doesn't exist anymore" + (let [{:keys [status headers body]} + @(handler {:path-params {:id "0" :vid "1"}})] + + (is (= 404 status)) + + (testing "has no Last-Modified header" + (is (nil? (get headers "Last-Modified")))) + + (testing "has no ETag header" + (is (nil? (get headers "ETag")))) + + (given body + :fhir/type := :fhir/OperationOutcome + [:issue 0 :severity] := #fhir/code"error" + [:issue 0 :code] := #fhir/code"not-found" + [:issue 0 :diagnostics] := "Resource `Patient/0` with version `1` was not found."))) + + (testing "current version still exists" + (let [{:keys [status headers body]} + @(handler {:path-params {:id "0" :vid "2"}})] + + (is (= 200 status)) + + (testing "Transaction time in Last-Modified header" + (is (= "Thu, 1 Jan 1970 00:00:00 GMT" (get headers "Last-Modified")))) + + (testing "Version in ETag header" + ;; 2 is the T of the transaction of the resource deletion + (is (= "W/\"2\"" (get headers "ETag")))) + + (given body + :fhir/type := :fhir/Patient + :id := "0" + :active := true))) + + (testing "version 3 doesn't exist" + (let [{:keys [status headers body]} + @(handler {:path-params {:id "0" :vid "3"}})] + + (is (= 404 status)) + + (testing "has no Last-Modified header" + (is (nil? (get headers "Last-Modified")))) + + (testing "has no ETag header" + (is (nil? (get headers "ETag")))) + + (given body + :fhir/type := :fhir/OperationOutcome + [:issue 0 :severity] := #fhir/code"error" + [:issue 0 :code] := #fhir/code"not-found" + [:issue 0 :diagnostics] := "Resource `Patient/0` with version `3` was not found.")))))) diff --git a/modules/jepsen/Makefile b/modules/jepsen/Makefile index 82df7dac7..bc9def866 100644 --- a/modules/jepsen/Makefile +++ b/modules/jepsen/Makefile @@ -13,12 +13,24 @@ test: prep test-coverage: true +deps-tree: + clojure -X:deps tree + +deps-list: + clojure -X:deps list + register-test-fast: prep clojure -M:register test --concurrency 16 --time-limit 60 -n localhost:8080 --delta-time 0.01 register-test-slow: prep clojure -M:register test --concurrency 16 --time-limit 60 -n localhost:8080 --delta-time 0.1 +resource-history-test-fast: prep + clojure -J-Xmx4g -M:resource-history test --concurrency 16 --time-limit 60 -n localhost:8080 --delta-time 0.02 + +resource-history-test-slow: prep + clojure -J-Xmx4g -M:resource-history test --concurrency 16 --time-limit 60 -n localhost:8080 --delta-time 0.1 + cloc-prod: cloc src @@ -28,4 +40,4 @@ cloc-test: clean: rm -rf .clj-kondo/.cache .cpcache target store -.PHONY: fmt lint prep test test-coverage cloc-prod cloc-test clean +.PHONY: fmt lint prep test test-coverage deps-tree deps-list cloc-prod cloc-test clean diff --git a/modules/jepsen/deps.edn b/modules/jepsen/deps.edn index 549ac15b7..1705fe116 100644 --- a/modules/jepsen/deps.edn +++ b/modules/jepsen/deps.edn @@ -21,4 +21,7 @@ :main-opts ["-m" "kaocha.runner"]} :register - {:main-opts ["-m" "blaze.jepsen.register"]}}} + {:main-opts ["-m" "blaze.jepsen.register"]} + + :resource-history + {:main-opts ["-m" "blaze.jepsen.resource-history"]}}} diff --git a/modules/jepsen/src/blaze/jepsen/resource_history.clj b/modules/jepsen/src/blaze/jepsen/resource_history.clj new file mode 100644 index 000000000..b2f24ea4a --- /dev/null +++ b/modules/jepsen/src/blaze/jepsen/resource_history.clj @@ -0,0 +1,128 @@ +(ns blaze.jepsen.resource-history + (:require + [blaze.anomaly :as ba] + [blaze.async.comp :as ac] + [blaze.fhir-client :as fhir-client] + [blaze.fhir.spec.type :as type] + [blaze.fhir.structure-definition-repo] + [blaze.jepsen.util :as u] + [clojure.tools.logging :refer [info]] + [hato.client :as hc] + [integrant.core :as ig] + [jepsen.checker :as checker] + [jepsen.cli :as cli] + [jepsen.client :as client] + [jepsen.generator :as gen] + [jepsen.tests :as tests] + [knossos.model :as model]) + (:import + [knossos.model Model])) + +(set! *warn-on-reflection* true) + +(ig/init {:blaze.fhir/structure-definition-repo {}}) + +(defn read-history "Reads all history values." [_ _] + {:type :invoke :f :read :value nil}) + +(defn add-history "Adds a random value to the history." [_ _] + {:type :invoke :f :add :value (str (random-uuid))}) + +(defn reset-history "Resets the history to only it's first value." [_ _] + {:type :invoke :f :reset :value nil}) + +(defrecord History [values] + Model + (step [r op] + (condp identical? (:f op) + :add (History. (cons (:value op) values)) + :reset (History. (some-> (first values) list)) + :read (if (or (nil? (:value op)) ; We don't know what the read was + (= values (:value op))) ; Read was a specific value + r + (model/inconsistent + (str (pr-str values) "β‰ " (pr-str (:value op))))))) + + Object + (toString [_] (pr-str values))) + +(defn client-read-history [{:keys [base-uri] :as context} id] + @(-> (fhir-client/history-instance base-uri "Patient" id context) + (ac/then-apply + (fn [versions] + {:type :ok :value (map (comp :value first :identifier) versions)})) + (ac/exceptionally + (fn [e] + {:type (if (ba/not-found? e) :ok :fail) :value nil})))) + +(defn client-add-history [{:keys [base-uri] :as context} id value] + @(-> (fhir-client/update + base-uri + {:fhir/type :fhir/Patient :id id + :identifier [(type/map->Identifier {:value value})]} + context) + (ac/then-apply (constantly {:type :ok})) + (ac/exceptionally (constantly {:type :fail})))) + +(defn client-reset-history [{:keys [base-uri] :as context} id] + @(-> (fhir-client/delete-history base-uri "Patient" id context) + (ac/then-apply (constantly {:type :ok})) + (ac/exceptionally (fn [e] (prn e) {:type :fail})))) + +(defrecord Client [context] + client/Client + (open! [this _test node] + (info "Open client on node" node) + (update this :context assoc + :base-uri (str "http://" node "/fhir") + :http-client (hc/build-http-client {:connect-timeout 10000}))) + + (setup! [this _test] + this) + + (invoke! [_ test op] + (case (:f op) + :read (merge op (client-read-history context (:id test))) + :add (merge op (client-add-history context (:id test) (:value op))) + :reset (merge op (client-reset-history context (:id test))))) + + (teardown! [this _test] + this) + + (close! [_ _test])) + +(defn blaze-test + "Given an options map from the command line runner (e.g. :nodes, :ssh, + :concurrency, ...), constructs a test map." + [opts] + (merge + tests/noop-test + {:pure-generators true + :name "resource-history" + :remote (u/->Remote) + :client (->Client {}) + :checker (checker/linearizable + {:model (->History nil) + :algorithm :linear}) + :generator (->> (gen/mix [read-history add-history]) + (gen/limit 120) + (gen/then (gen/once reset-history)) + (gen/cycle) + (gen/stagger (:delta-time opts)) + (gen/nemesis []) + (gen/time-limit (:time-limit opts)))} + opts)) + +(def cli-opts + "Additional command line options." + [[nil "--id ID" "The ID of the patient to use." :default (str (random-uuid))] + [nil "--delta-time s" "The duration between requests." + :default 0.1 + :parse-fn parse-double]]) + +(defn -main + "Handles command line arguments. Can either run a test, or a web server for + browsing results." + [& args] + (cli/run! (cli/single-test-cmd {:test-fn blaze-test :opt-spec cli-opts}) + args)) diff --git a/modules/rest-api/src/blaze/rest_api/routes.clj b/modules/rest-api/src/blaze/rest_api/routes.clj index f8f2bb9c8..d390918e1 100644 --- a/modules/rest-api/src/blaze/rest_api/routes.clj +++ b/modules/rest-api/src/blaze/rest_api/routes.clj @@ -47,10 +47,6 @@ {:name :snapshot-db :wrap db/wrap-snapshot-db}) -(def ^:private wrap-versioned-instance-db - {:name :versioned-instance-db - :wrap db/wrap-versioned-instance-db}) - (def ^:private wrap-ensure-form-body {:name :ensure-form-body :wrap ensure-form-body/wrap-ensure-form-body}) @@ -197,12 +193,16 @@ :middleware [[wrap-db node db-sync-timeout] wrap-link-headers] :handler (-> interactions :history-instance - :blaze.rest-api.interaction/handler)}))] + :blaze.rest-api.interaction/handler)}) + (contains? interactions :delete-history) + (assoc :delete {:interaction "delete-history" + :handler (-> interactions :delete-history + :blaze.rest-api.interaction/handler)}))] ["/{vid}" (cond-> {:name (keyword name "versioned-instance")} (contains? interactions :vread) (assoc :get {:interaction "vread" - :middleware [[wrap-versioned-instance-db node db-sync-timeout]] + :middleware [[wrap-db node db-sync-timeout]] :handler (-> interactions :vread :blaze.rest-api.interaction/handler)}))]]] (not batch?) diff --git a/modules/rest-api/src/blaze/rest_api/spec.clj b/modules/rest-api/src/blaze/rest_api/spec.clj index 499002f45..797e9d532 100644 --- a/modules/rest-api/src/blaze/rest_api/spec.clj +++ b/modules/rest-api/src/blaze/rest_api/spec.clj @@ -46,6 +46,7 @@ :update :patch :delete + :delete-history :conditional-delete-type :history-instance :history-type diff --git a/modules/rest-api/test/blaze/rest_api/routes_test.clj b/modules/rest-api/test/blaze/rest_api/routes_test.clj index 7c18c68b9..1d6ad188f 100644 --- a/modules/rest-api/test/blaze/rest_api/routes_test.clj +++ b/modules/rest-api/test/blaze/rest_api/routes_test.clj @@ -108,6 +108,9 @@ :delete #:blaze.rest-api.interaction {:handler (handler ::delete)} + :delete-history + #:blaze.rest-api.interaction + {:handler (handler ::delete-history)} :conditional-delete-type #:blaze.rest-api.interaction {:handler (handler ::conditional-delete-type)} @@ -185,6 +188,7 @@ "/Patient/0" :put "update" "/Patient/0" :delete "delete" "/Patient/0/_history" :get "history-instance" + "/Patient/0/_history" :delete "delete-history" "/Patient/0/__history-page/0" :get "history-instance" "/Patient/0/_history/42" :get "vread" "/Patient/0/$everything" :get "operation-instance-everything" @@ -227,6 +231,7 @@ "/Patient/0" :put ::update "/Patient/0" :delete ::delete "/Patient/0/_history" :get ::history-instance + "/Patient/0/_history" :delete ::delete-history "/Patient/0/__history-page/0" :get ::history-instance "/Patient/0/_history/42" :get ::vread "/Patient/0/$everything" :get ::everything @@ -274,8 +279,9 @@ "/Patient/0" :put [:observe-request-duration :params :output :error :forwarded :sync :resource] "/Patient/0" :delete [:observe-request-duration :params :output :error :forwarded :sync] "/Patient/0/_history" :get [:observe-request-duration :params :output :error :forwarded :sync :db :link-headers] + "/Patient/0/_history" :delete [:observe-request-duration :params :output :error :forwarded :sync] "/Patient/0/__history-page/0" :get [:observe-request-duration :params :output :error :forwarded :sync :decrypt-page-id :snapshot-db :link-headers] - "/Patient/0/_history/42" :get [:observe-request-duration :params :output :error :forwarded :sync :versioned-instance-db] + "/Patient/0/_history/42" :get [:observe-request-duration :params :output :error :forwarded :sync :db] "/Patient/0/$everything" :get [:observe-request-duration :params :output :error :forwarded :sync :db] "/Patient/0/__everything-page/0" :get [:observe-request-duration :params :output :error :forwarded :sync :decrypt-page-id :snapshot-db] "/Patient/0/Condition" :get [:observe-request-duration :params :output :error :forwarded :sync :db :link-headers] @@ -308,7 +314,8 @@ "/Patient/0" :put [:observe-request-duration :params :output :error :forwarded :sync :resource] "/Patient/0" :delete [:observe-request-duration :params :output :error :forwarded :sync] "/Patient/0/_history" :get [:observe-request-duration :params :output :error :forwarded :sync :db :link-headers] - "/Patient/0/_history/42" :get [:observe-request-duration :params :output :error :forwarded :sync :versioned-instance-db] + "/Patient/0/_history" :delete [:observe-request-duration :params :output :error :forwarded :sync] + "/Patient/0/_history/42" :get [:observe-request-duration :params :output :error :forwarded :sync :db] "/Patient/0/Condition" :get [:observe-request-duration :params :output :error :forwarded :sync :db :link-headers] "/Patient/0/Observation" :get [:observe-request-duration :params :output :error :forwarded :sync :db :link-headers] "/$compact-db" :get [:observe-request-duration :params :output :error :forwarded :sync :db] diff --git a/modules/rest-util/src/blaze/handler/fhir/util.clj b/modules/rest-util/src/blaze/handler/fhir/util.clj index 721aea17e..2e8d0d487 100644 --- a/modules/rest-util/src/blaze/handler/fhir/util.clj +++ b/modules/rest-util/src/blaze/handler/fhir/util.clj @@ -4,8 +4,8 @@ (:require [blaze.anomaly :as ba :refer [if-ok]] [blaze.async.comp :as ac :refer [do-sync]] + [blaze.coll.core :as coll] [blaze.db.api :as d] - [blaze.db.impl.index.resource-handle :as rh] [blaze.fhir.spec :as fhir-spec] [blaze.fhir.spec.type :as type] [blaze.fhir.spec.type.system :as system] @@ -140,25 +140,35 @@ [{:blaze.db/keys [t]}] (str "W/\"" t "\"")) +(defn- deleted-anom [db {:fhir/keys [type] :keys [id t]}] + (let [tx (d/tx db t)] + (ba/not-found + (format "Resource `%s/%s` was deleted." (name type) id) + :http/status 410 + :http/headers + [["Last-Modified" (last-modified tx)] + ["ETag" (etag tx)]] + :fhir/issue "deleted"))) + (defn- resource-handle [db type id] (if-let [{:keys [op] :as handle} (d/resource-handle db type id)] (if (identical? :delete op) - (let [tx (d/tx db (rh/t handle))] - (ba/not-found - (format "Resource `%s/%s` was deleted." type id) - :http/status 410 - :http/headers - [["Last-Modified" (last-modified tx)] - ["ETag" (etag tx)]] - :fhir/issue "deleted")) + (deleted-anom db handle) handle) - (ba/not-found - (format "Resource `%s/%s` was not found." type id) - :fhir/issue "not-found"))) + (ba/not-found (format "Resource `%s/%s` was not found." type id)))) + +(defn- pull* [db resource-handle] + (if (ba/anomaly? resource-handle) + (ac/completed-future resource-handle) + (-> (d/pull db resource-handle) + (ac/exceptionally + #(assoc % + ::anom/category ::anom/fault + :fhir/issue "incomplete"))))) (defn pull "Returns a CompletableFuture that will complete with the resource with `type` - and `id` if not deleted in `db` or an anomaly otherwise. + and `id` if not deleted in `db` or complete exceptionally. Returns a not-found anomaly if the resource was not found or is deleted. In case it is deleted, sets :http/status to 410 and :http/headers Last-Modified @@ -167,13 +177,38 @@ Functions applied after the returned future are executed on the common ForkJoinPool." [db type id] - (if-ok [resource-handle (resource-handle db type id)] - (-> (d/pull db resource-handle) - (ac/exceptionally - #(assoc % - ::anom/category ::anom/fault - :fhir/issue "incomplete"))) - ac/completed-future)) + (pull* db (resource-handle db type id))) + +(defn- historic-resource-handle-not-found-anom [type id t] + (ba/not-found + (format "Resource `%s/%s` with version `%d` was not found." type id t))) + +(defn- deleted-version-msg [{:fhir/keys [type] :keys [id t]}] + (format "Resource `%s/%s` was deleted in version `%d`." (name type) id t)) + +(defn- deleted-version-anom [db handle] + (assoc (deleted-anom db handle) ::anom/message (deleted-version-msg handle))) + +(defn- historic-resource-handle [db type id t] + (if-let [handle (coll/first (d/instance-history db type id t))] + (cond + (not= t (:t handle)) (historic-resource-handle-not-found-anom type id t) + (identical? :delete (:op handle)) (deleted-version-anom db handle) + :else handle) + (historic-resource-handle-not-found-anom type id t))) + +(defn pull-historic + "Returns a CompletableFuture that will complete with the resource with `type`, + `id` and `t` (version) if not deleted in `db` or complete exceptionally. + + Returns a not-found anomaly if the resource was not found or is deleted. In + case it is deleted, sets :http/status to 410 and :http/headers Last-Modified + and ETag to appropriate values. + + Functions applied after the returned future are executed on the common + ForkJoinPool." + [db type id t] + (pull* db (historic-resource-handle db type id t))) (defn- timeout-msg [timeout] (format "Timeout while trying to acquire the latest known database state. At least one known transaction hasn't been completed yet. Please try to lower the transaction load or increase the timeout of %d ms by setting DB_SYNC_TIMEOUT to a higher value if you see this often." timeout)) diff --git a/modules/rest-util/src/blaze/handler/fhir/util_spec.clj b/modules/rest-util/src/blaze/handler/fhir/util_spec.clj index 071d5ca87..34bc958ad 100644 --- a/modules/rest-util/src/blaze/handler/fhir/util_spec.clj +++ b/modules/rest-util/src/blaze/handler/fhir/util_spec.clj @@ -12,6 +12,10 @@ [cognitect.anomalies :as anom] [reitit.core :as reitit])) +(s/fdef fhir-util/parse-nat-long + :args (s/cat :s string?) + :ret (s/nilable nat-int?)) + (s/fdef fhir-util/t :args (s/cat :query-params (s/nilable :ring.request/query-params)) :ret (s/nilable :blaze.db/t)) @@ -69,6 +73,11 @@ :args (s/cat :db :blaze.db/db :type :fhir.resource/type :id :blaze.resource/id) :ret ac/completable-future?) +(s/fdef fhir-util/pull-historic + :args (s/cat :db :blaze.db/db :type :fhir.resource/type :id :blaze.resource/id + :t :blaze.db/t) + :ret ac/completable-future?) + (s/fdef fhir-util/sync :args (s/cat :node :blaze.db/node :t (s/? :blaze.db/t) :timeout ::rest-api/db-sync-timeout) diff --git a/modules/rest-util/src/blaze/handler/util.clj b/modules/rest-util/src/blaze/handler/util.clj index e2283b2c5..6d8076157 100644 --- a/modules/rest-util/src/blaze/handler/util.clj +++ b/modules/rest-util/src/blaze/handler/util.clj @@ -152,7 +152,9 @@ * :fhir/operation-outcome - will go into `OperationOutcome.issue.details` as code with system http://terminology.hl7.org/CodeSystem/operation-outcome - * :fhir.issue/expression - will go into `OperationOutcome.issue.expression`" + * :fhir.issue/expression - will go into `OperationOutcome.issue.expression` + * :http/status - the HTTP status to use + * :http/headers - a list of tuples of header name and header value" [error] (error-response* error diff --git a/modules/rest-util/src/blaze/handler/util_spec.clj b/modules/rest-util/src/blaze/handler/util_spec.clj index 04ec42539..b2c54fb2b 100644 --- a/modules/rest-util/src/blaze/handler/util_spec.clj +++ b/modules/rest-util/src/blaze/handler/util_spec.clj @@ -10,6 +10,10 @@ :args (s/cat :headers (s/nilable map?) :name string?) :ret (s/nilable keyword?)) +(s/fdef handler-util/error-response + :args (s/cat :error some?) + :ret map?) + (s/fdef handler-util/luid :args (s/cat :context (s/keys :req-un [:blaze/clock :blaze/rng-fn])) :ret :blaze/luid) diff --git a/modules/rest-util/src/blaze/middleware/fhir/db.clj b/modules/rest-util/src/blaze/middleware/fhir/db.clj index 13b690ee9..9ab30060f 100644 --- a/modules/rest-util/src/blaze/middleware/fhir/db.clj +++ b/modules/rest-util/src/blaze/middleware/fhir/db.clj @@ -33,20 +33,3 @@ (ac/then-compose #(handler (assoc request :blaze/db %)))) (ac/completed-future (ba/incorrect (format "Missing or invalid `__t` query param `%s`." (get params "__t")))))))) - -(defn wrap-versioned-instance-db - "Database wrapping for versioned read requests. - - The `t` of the database state is taken from the path param `vid`." - [handler node timeout] - (fn [{{:keys [vid]} :path-params :as request}] - (if (:blaze/db request) - (handler request) - (if-let [t (some-> vid fhir-util/parse-nat-long)] - (-> (fhir-util/sync node t timeout) - (ac/then-compose #(handler (assoc request :blaze/db %)))) - (ac/completed-future - (ba/incorrect - (format "Resource versionId `%s` is invalid." vid) - :fhir/issue "value" - :fhir/operation-outcome "MSG_ID_INVALID")))))) diff --git a/modules/rest-util/src/blaze/middleware/fhir/db_spec.clj b/modules/rest-util/src/blaze/middleware/fhir/db_spec.clj index 2ff115ce0..d5a3c6bdf 100644 --- a/modules/rest-util/src/blaze/middleware/fhir/db_spec.clj +++ b/modules/rest-util/src/blaze/middleware/fhir/db_spec.clj @@ -9,6 +9,3 @@ (s/fdef db/wrap-snapshot-db :args (s/cat :handler ifn? :node :blaze.db/node :timeout pos-int?)) - -(s/fdef db/wrap-versioned-instance-db - :args (s/cat :handler ifn? :node :blaze.db/node :timeout pos-int?)) diff --git a/modules/rest-util/test/blaze/handler/fhir/util_test.clj b/modules/rest-util/test/blaze/handler/fhir/util_test.clj index 4eb44cfda..86e1d273b 100644 --- a/modules/rest-util/test/blaze/handler/fhir/util_test.clj +++ b/modules/rest-util/test/blaze/handler/fhir/util_test.clj @@ -216,8 +216,7 @@ (with-system [{:blaze.db/keys [node]} mem-node-config] (given-failed-future (fhir-util/pull (d/db node) "Patient" "0") ::anom/category := ::anom/not-found - ::anom/message := "Resource `Patient/0` was not found." - :fhir/issue := "not-found"))) + ::anom/message := "Resource `Patient/0` was not found."))) (testing "deleted" (with-system-data [{:blaze.db/keys [node]} mem-node-config] @@ -251,6 +250,63 @@ ::anom/category := ::anom/fault :fhir/issue := "incomplete"))))) +(deftest pull-historic-test + (testing "not-found" + (with-system [{:blaze.db/keys [node]} mem-node-config] + (given-failed-future (fhir-util/pull-historic (d/db node) "Patient" "0" 0) + ::anom/category := ::anom/not-found + ::anom/message := "Resource `Patient/0` with version `0` was not found.")) + + (with-system-data [{:blaze.db/keys [node]} mem-node-config] + [[[:put {:fhir/type :fhir/Patient :id "0" :active false}]] + [[:put {:fhir/type :fhir/Patient :id "0" :active true}]] + [[:delete-history "Patient" "0"]]] + + (given-failed-future (fhir-util/pull-historic (d/db node) "Patient" "0" 1) + ::anom/category := ::anom/not-found + ::anom/message := "Resource `Patient/0` with version `1` was not found."))) + + (testing "found" + (with-system-data [{:blaze.db/keys [node]} mem-node-config] + [[[:put {:fhir/type :fhir/Patient :id "0" :active false}]] + [[:put {:fhir/type :fhir/Patient :id "0" :active true}]]] + + (testing "version 1" + (given @(mtu/assoc-thread-name (fhir-util/pull-historic (d/db node) "Patient" "0" 1)) + :fhir/type := :fhir/Patient + :id := "0" + :active := false + [meta :thread-name] :? mtu/common-pool-thread?)) + + (testing "version 2" + (given @(mtu/assoc-thread-name (fhir-util/pull-historic (d/db node) "Patient" "0" 2)) + :fhir/type := :fhir/Patient + :id := "0" + :active := true + [meta :thread-name] :? mtu/common-pool-thread?))) + + (testing "deleted version" + (with-system-data [{:blaze.db/keys [node]} mem-node-config] + [[[:delete "Patient" "0"]]] + + (given-failed-future (fhir-util/pull-historic (d/db node) "Patient" "0" 1) + ::anom/category := ::anom/not-found + ::anom/message := "Resource `Patient/0` was deleted in version `1`." + :http/status := 410 + :http/headers := [["Last-Modified" "Thu, 1 Jan 1970 00:00:00 GMT"] + ["ETag" "W/\"1\""]] + :fhir/issue := "deleted")))) + + (testing "pull error" + (with-redefs + [d/pull (fn [_ _] (ac/completed-future (ba/fault)))] + (with-system-data [{:blaze.db/keys [node]} mem-node-config] + [[[:put {:fhir/type :fhir/Patient :id "0"}]]] + + (given-failed-future (fhir-util/pull-historic (d/db node) "Patient" "0" 1) + ::anom/category := ::anom/fault + :fhir/issue := "incomplete"))))) + (deftest match-url-test (testing "system-level" (is (nil? (fhir-util/match-url "")))) diff --git a/modules/rest-util/test/blaze/middleware/fhir/db_test.clj b/modules/rest-util/test/blaze/middleware/fhir/db_test.clj index f0e3c7bb8..8b593c577 100644 --- a/modules/rest-util/test/blaze/middleware/fhir/db_test.clj +++ b/modules/rest-util/test/blaze/middleware/fhir/db_test.clj @@ -24,7 +24,6 @@ (st/instrument) (st/unstrument `db/wrap-db) (st/unstrument `db/wrap-snapshot-db) - (st/unstrument `db/wrap-versioned-instance-db) (st/unstrument `fhir-util/sync) (st/unstrument `d/sync) (st/unstrument `d/as-of) @@ -124,54 +123,3 @@ (given-failed-future ((db/wrap-snapshot-db handler ::node timeout) {:params {"__t" "114148"}}) ::anom/category := ::anom/fault ::anom/message := "msg-115945")))) - -(deftest wrap-versioned-instance-db-test - (testing "uses existing database value" - (is (= ::db @((db/wrap-versioned-instance-db handler ::node timeout) {:blaze/db ::db})))) - - (testing "with missing or invalid vid" - (doseq [vid [nil "a" "-1"]] - (given-failed-future ((db/wrap-versioned-instance-db handler ::node timeout) {:path-params {:vid vid}}) - ::anom/category := ::anom/incorrect - ::anom/message := (format "Resource versionId `%s` is invalid." vid) - :fhir/issue := "value" - :fhir/operation-outcome := "MSG_ID_INVALID"))) - - (testing "uses the vid for database value acquisition" - (with-redefs - [d/sync - (fn [node t] - (assert (= ::node node)) - (assert (= 114418 t)) - (ac/completed-future ::db)) - d/as-of - (fn [db t] - (assert (= ::db db)) - (assert (= 114418 t)) - ::as-of-db)] - - (is (= ::as-of-db @((db/wrap-versioned-instance-db handler ::node timeout) {:path-params {:vid "114418"}}))))) - - (testing "fails on timeout" - (with-redefs - [d/sync - (fn [node t] - (assert (= ::node node)) - (assert (= 213957 t)) - (ac/supply-async (constantly ::db) (ac/delayed-executor 1 TimeUnit/SECONDS)))] - - (given-failed-future ((db/wrap-versioned-instance-db handler ::node timeout) {:path-params {:vid "213957"}}) - ::anom/category := ::anom/busy - ::anom/message := "Timeout while trying to acquire the database state with t=213957. The indexer has probably fallen behind. Please try to lower the transaction load or increase the timeout of 100 ms by setting DB_SYNC_TIMEOUT to a higher value if you see this often."))) - - (testing "fails on other sync error" - (with-redefs - [d/sync - (fn [node t] - (assert (= ::node node)) - (assert (= 120213 t)) - (ac/completed-future (ba/fault "msg-120219")))] - - (given-failed-future ((db/wrap-versioned-instance-db handler ::node timeout) {:path-params {:vid "120213"}}) - ::anom/category := ::anom/fault - ::anom/message := "msg-120219")))) diff --git a/modules/spec/src/blaze/spec.clj b/modules/spec/src/blaze/spec.clj index 004ea1838..6763ea1c4 100644 --- a/modules/spec/src/blaze/spec.clj +++ b/modules/spec/src/blaze/spec.clj @@ -154,6 +154,12 @@ (s/or :coll (s/coll-of string?) :string string?)) +(s/def :http/status + (s/and int? #(<= 100 % 599))) + +(s/def :http/headers + (s/coll-of (s/tuple string? string?))) + ;; ---- Clojure --------------------------------------------------------------- (s/def :clojure/binding-form (s/or :symbol simple-symbol? diff --git a/resources/blaze.edn b/resources/blaze.edn index 720f6d144..bc13817a4 100644 --- a/resources/blaze.edn +++ b/resources/blaze.edn @@ -60,13 +60,16 @@ {:handler #blaze/ref :blaze.interaction/read} :vread #:blaze.rest-api.interaction - {:handler #blaze/ref :blaze.interaction/read} + {:handler #blaze/ref :blaze.interaction/vread} :update #:blaze.rest-api.interaction {:handler #blaze/ref :blaze.interaction/update} :delete #:blaze.rest-api.interaction {:handler #blaze/ref :blaze.interaction/delete} + :delete-history + #:blaze.rest-api.interaction + {:handler #blaze/ref :blaze.interaction/delete-history} :conditional-delete-type #:blaze.rest-api.interaction {:handler #blaze/ref :blaze.interaction/conditional-delete-type} @@ -142,10 +145,14 @@ :blaze.interaction/delete {:node #blaze/ref :blaze.db.main/node} + :blaze.interaction/delete-history + {:node #blaze/ref :blaze.db.main/node} + :blaze.interaction/conditional-delete-type {:node #blaze/ref :blaze.db.main/node} :blaze.interaction/read {} + :blaze.interaction/vread {} :blaze.interaction/search-system {:clock #blaze/ref :blaze/clock diff --git a/test/blaze/system_test.clj b/test/blaze/system_test.clj index b7cd5ebe3..8bfafd958 100644 --- a/test/blaze/system_test.clj +++ b/test/blaze/system_test.clj @@ -6,11 +6,13 @@ [blaze.fhir.test-util :refer [structure-definition-repo]] [blaze.interaction.conditional-delete-type] [blaze.interaction.delete] + [blaze.interaction.delete-history] [blaze.interaction.history.type] [blaze.interaction.read] [blaze.interaction.search-system] [blaze.interaction.search-type] [blaze.interaction.transaction] + [blaze.interaction.vread] [blaze.middleware.fhir.decrypt-page-id :as decrypt-page-id] [blaze.middleware.fhir.decrypt-page-id-spec] [blaze.module.test-util :refer [with-system]] @@ -127,8 +129,11 @@ :rng-fn (ig/ref :blaze.test/fixed-rng-fn) :db-sync-timeout 10000} :blaze.interaction/read {} + :blaze.interaction/vread {} :blaze.interaction/delete {:node (ig/ref :blaze.db/node)} + :blaze.interaction/delete-history + {:node (ig/ref :blaze.db/node)} :blaze.interaction/conditional-delete-type {:node (ig/ref :blaze.db/node)} :blaze.interaction/search-system @@ -172,9 +177,15 @@ {:read #:blaze.rest-api.interaction {:handler (ig/ref :blaze.interaction/read)} + :vread + #:blaze.rest-api.interaction + {:handler (ig/ref :blaze.interaction/vread)} :delete #:blaze.rest-api.interaction {:handler (ig/ref :blaze.interaction/delete)} + :delete-history + #:blaze.rest-api.interaction + {:handler (ig/ref :blaze.interaction/delete-history)} :conditional-delete-type #:blaze.rest-api.interaction {:handler (ig/ref :blaze.interaction/conditional-delete-type)} @@ -242,10 +253,56 @@ :body := nil))) (deftest read-test - (with-system [{:blaze/keys [rest-api]} config] - (given (call rest-api {:request-method :get :uri "/Patient/0"}) - :status := 404 - [:body fhir-spec/parse-json :resourceType] := "OperationOutcome"))) + (with-system-data [{:blaze/keys [rest-api]} config] + [[[:put {:fhir/type :fhir/Patient :id "0"}]]] + + (testing "success" + (given (call rest-api {:request-method :get :uri "/Patient/0"}) + :status := 200 + [:body fhir-spec/parse-json :resourceType] := "Patient")) + + (testing "not found" + (given (call rest-api {:request-method :get :uri "/Patient/1"}) + :status := 404 + [:body fhir-spec/parse-json :resourceType] := "OperationOutcome")))) + +(deftest vread-test + (with-system-data [{:blaze/keys [rest-api]} config] + [[[:put {:fhir/type :fhir/Patient :id "0" :active false}]] + [[:put {:fhir/type :fhir/Patient :id "0" :active true}]]] + + (testing "current version" + (given (call rest-api {:request-method :get :uri "/Patient/0/_history/2"}) + :status := 200 + [:body fhir-spec/parse-json :active] := true)) + + (testing "older version" + (given (call rest-api {:request-method :get :uri "/Patient/0/_history/1"}) + :status := 200 + [:body fhir-spec/parse-json :active] := false)) + + (doseq [t [0 3]] + (testing (format "version %d doesn't exist" t) + (given (call rest-api {:request-method :get :uri (format "/Patient/0/_history/%d" t)}) + :status := 404 + [:body fhir-spec/parse-json :resourceType] := "OperationOutcome")))) + + (testing "with deleted history" + (with-system-data [{:blaze/keys [rest-api]} config] + [[[:put {:fhir/type :fhir/Patient :id "0" :active false}]] + [[:put {:fhir/type :fhir/Patient :id "0" :active true}]] + [[:delete-history "Patient" "0"]]] + + (testing "current version" + (given (call rest-api {:request-method :get :uri "/Patient/0/_history/2"}) + :status := 200 + [:body fhir-spec/parse-json :active] := true)) + + (doseq [t [0 1 3]] + (testing (format "version %d doesn't exist" t) + (given (call rest-api {:request-method :get :uri (format "/Patient/0/_history/%d" t)}) + :status := 404 + [:body fhir-spec/parse-json :resourceType] := "OperationOutcome")))))) (def read-bundle {:fhir/type :fhir/Bundle @@ -302,6 +359,19 @@ :status := 410 [:body fhir-spec/parse-json :resourceType] := "OperationOutcome"))) +(deftest delete-history-test + (with-system-data [{:blaze/keys [rest-api]} config] + [[[:put {:fhir/type :fhir/Patient :id "0" :active false}]] + [[:put {:fhir/type :fhir/Patient :id "0" :active true}]]] + + (given (call rest-api {:request-method :delete :uri "/Patient/0/_history"}) + :status := 204 + :body := nil) + + (given (call rest-api {:request-method :get :uri "/Patient/0/_history/1"}) + :status := 404 + [:body fhir-spec/parse-json :resourceType] := "OperationOutcome"))) + (deftest conditional-delete-type-test (with-system [{:blaze/keys [rest-api]} config] (given (call rest-api {:request-method :delete :uri "/Patient"})