From dac45e74985594b9d84097f55f68eb35364901f4 Mon Sep 17 00:00:00 2001 From: Alexander Kiel Date: Tue, 21 Jul 2026 12:47:01 +0000 Subject: [PATCH] Implement Evaluating an Existing Measure Closes: #104 --- .github/scripts/evaluate-measure-param.sh | 18 +- .../scripts/evaluate-measure-stratifier.sh | 20 +- .github/scripts/evaluate-measure.sh | 18 +- .github/scripts/util.sh | 50 +++ README.md | 16 + cmd/evaluateMeasure.go | 290 +++++++++++------- cmd/evalueMeasure_test.go | 167 +++++++++- fhir/client.go | 25 +- fhir/client_test.go | 83 +++++ 9 files changed, 522 insertions(+), 165 deletions(-) diff --git a/.github/scripts/evaluate-measure-param.sh b/.github/scripts/evaluate-measure-param.sh index fbf0de4..7990abe 100755 --- a/.github/scripts/evaluate-measure-param.sh +++ b/.github/scripts/evaluate-measure-param.sh @@ -1,22 +1,16 @@ #!/bin/bash set -euo pipefail +SCRIPT_DIR="$(dirname "$(readlink -f "$0")")" +. "$SCRIPT_DIR/util.sh" + BASE="http://localhost:8080/fhir" NAME="$1" PARAM_NAME="$2" PARAM_VALUE="$3" EXPECTED_COUNT="$4" -if ! REPORT=$(./blazectl --server "$BASE" evaluate-measure --parameter "$PARAM_NAME=$PARAM_VALUE" ".github/scripts/cql/$NAME.yml"); then - echo "Measure evaluation failed: $REPORT" - exit 1 -fi - -COUNT=$(echo "$REPORT" | jq '.group[0].population[0].count') +REPORT=$(evaluate_measure --parameter "$PARAM_NAME=$PARAM_VALUE" ".github/scripts/cql/$NAME.yml") +check_population_count "measure file with $PARAM_NAME = $PARAM_VALUE" "$REPORT" "$EXPECTED_COUNT" -if [ "$COUNT" = "$EXPECTED_COUNT" ]; then - echo "✅ count ($COUNT) equals the expected count for $PARAM_NAME = $PARAM_VALUE" -else - echo "🆘 count ($COUNT) != $EXPECTED_COUNT for $PARAM_NAME = $PARAM_VALUE" - exit 1 -fi +evaluate_existing_measure "$REPORT" "$EXPECTED_COUNT" --parameter "$PARAM_NAME=$PARAM_VALUE" diff --git a/.github/scripts/evaluate-measure-stratifier.sh b/.github/scripts/evaluate-measure-stratifier.sh index fd054e4..9b75728 100755 --- a/.github/scripts/evaluate-measure-stratifier.sh +++ b/.github/scripts/evaluate-measure-stratifier.sh @@ -1,23 +1,15 @@ #!/bin/bash set -euo pipefail +SCRIPT_DIR="$(dirname "$(readlink -f "$0")")" +. "$SCRIPT_DIR/util.sh" + BASE="http://localhost:8080/fhir" NAME="$1" EXPECTED_COUNT="$2" -if ! REPORT=$(./blazectl --server "$BASE" evaluate-measure ".github/scripts/cql/$NAME.yml"); then - echo "Measure evaluation failed: $REPORT" - exit 1 -fi - -COUNT=$(echo "$REPORT" | jq '.group[0].population[0].count') - -if [ "$COUNT" = "$EXPECTED_COUNT" ]; then - echo "✅ count ($COUNT) equals the expected count" -else - echo "🆘 count ($COUNT) != $EXPECTED_COUNT" - exit 1 -fi +REPORT=$(evaluate_measure ".github/scripts/cql/$NAME.yml") +check_population_count "measure file" "$REPORT" "$EXPECTED_COUNT" STRATIFIER_DATA=$(echo "$REPORT" | jq -r '.group[0].stratifier[0].stratum[] | [.value.text, .population[0].count] | @csv' | sort) EXPECTED_STRATIFIER_DATA=$(cat ".github/scripts/cql/$NAME.csv") @@ -29,3 +21,5 @@ else echo "$STRATIFIER_DATA" exit 1 fi + +evaluate_existing_measure "$REPORT" "$EXPECTED_COUNT" diff --git a/.github/scripts/evaluate-measure.sh b/.github/scripts/evaluate-measure.sh index 95a8816..7473ad7 100755 --- a/.github/scripts/evaluate-measure.sh +++ b/.github/scripts/evaluate-measure.sh @@ -1,20 +1,14 @@ #!/bin/bash set -euo pipefail +SCRIPT_DIR="$(dirname "$(readlink -f "$0")")" +. "$SCRIPT_DIR/util.sh" + BASE="http://localhost:8080/fhir" NAME="$1" EXPECTED_COUNT="$2" -if ! REPORT=$(./blazectl --server "$BASE" evaluate-measure ".github/scripts/cql/$NAME.yml"); then - echo "Measure evaluation failed: $REPORT" - exit 1 -fi - -COUNT=$(echo "$REPORT" | jq '.group[0].population[0].count') +REPORT=$(evaluate_measure ".github/scripts/cql/$NAME.yml") +check_population_count "measure file" "$REPORT" "$EXPECTED_COUNT" -if [ "$COUNT" = "$EXPECTED_COUNT" ]; then - echo "✅ count ($COUNT) equals the expected count" -else - echo "🆘 count ($COUNT) != $EXPECTED_COUNT" - exit 1 -fi +evaluate_existing_measure "$REPORT" "$EXPECTED_COUNT" diff --git a/.github/scripts/util.sh b/.github/scripts/util.sh index f4358da..9e2ca46 100644 --- a/.github/scripts/util.sh +++ b/.github/scripts/util.sh @@ -70,3 +70,53 @@ transact() { transact_return_representation() { curl -s -H 'Accept: application/fhir+json' -H "Content-Type: application/fhir+json" -H "Prefer: return=representation" -d @- "$1" } + +# Evaluates a measure with the given blazectl evaluate-measure arguments and +# prints the resulting MeasureReport. +evaluate_measure() { + local report + if ! report=$(./blazectl --server "$BASE" evaluate-measure "$@"); then + echo "Measure evaluation failed: $report" >&2 + exit 1 + fi + echo "$report" +} + +# Checks that the initial population count of the given MeasureReport matches +# the expected count. +check_population_count() { + local desc="$1" + local report="$2" + local expected_count="$3" + local count + count=$(echo "$report" | jq '.group[0].population[0].count') + if [ "$count" = "$expected_count" ]; then + echo "✅ $desc: count ($count) equals the expected count" + else + echo "🆘 $desc: count ($count) != $expected_count" + exit 1 + fi +} + +# Finds the resource ID of the Measure with the given canonical URL. +measure_id() { + curl -s -H 'Accept: application/fhir+json' "$BASE/Measure?url=$1" | jq -r '.entry[0].resource.id' +} + +# Evaluates the existing Measure referenced by the given MeasureReport again, +# once by its canonical URL and once by its resource ID, checking the initial +# population count each time. Additional blazectl evaluate-measure arguments +# can be given after the expected count. +evaluate_existing_measure() { + local report="$1" + local expected_count="$2" + shift 2 + local measure_url url_report + measure_url=$(echo "$report" | jq -r '.measure') + url_report=$(evaluate_measure --measure-url "$measure_url" "$@") + check_population_count "existing measure with canonical URL $measure_url" "$url_report" "$expected_count" + local measure_id id_report + measure_id=$(measure_id "$measure_url") + id_report=$(evaluate_measure --measure-id "$measure_id" "$@") + check_population_count "existing measure with ID $measure_id" "$id_report" "$expected_count" +} diff --git a/README.md b/README.md index f36e404..3c0c1c6 100644 --- a/README.md +++ b/README.md @@ -209,6 +209,22 @@ blazectl evaluate-measure --server "http://localhost:8080/fhir" \ When at least one parameter is given, the operation is invoked via `POST` with a FHIR `Parameters` body, because the `parameters` input of `$evaluate-measure` cannot be transmitted as a GET query parameter. +Instead of a measure file, an existing Measure on the server can be evaluated. In that case no resources are created. The Measure is referenced either by its canonical URL with the `--measure-url` flag: + +```sh +blazectl evaluate-measure --server "http://localhost:8080/fhir" \ + --measure-url "https://example.com/fhir/Measure/example" +``` + +or by its resource ID with the `--measure-id` flag: + +```sh +blazectl evaluate-measure --server "http://localhost:8080/fhir" \ + --measure-id DACG22F3LKPU7ZZ5 +``` + +The measure file argument and the `--measure-url` and `--measure-id` flags are mutually exclusive. The `--parameter` flag works the same for existing measures. + More comprehensive documentation can be found in the [Blaze CQL Queries Documentation][9]. ### Render Report diff --git a/cmd/evaluateMeasure.go b/cmd/evaluateMeasure.go index 597f134..bcd3774 100644 --- a/cmd/evaluateMeasure.go +++ b/cmd/evaluateMeasure.go @@ -27,6 +27,48 @@ import ( var forceSync bool var rawMeasureParameters []string var measureParameters []fm.ParametersParameter +var existingMeasureUrl string +var existingMeasureId string + +// measureRef identifies the Measure to evaluate: either by canonical URL via +// the type-level $evaluate-measure operation or by resource ID via the +// instance-level one. Exactly one of the two fields is set. +type measureRef struct { + url string + id string +} + +// description names the referenced Measure in user-facing messages. +func (ref measureRef) description() string { + if ref.id != "" { + return fmt.Sprintf("ID %s", ref.id) + } + return fmt.Sprintf("canonical URL %s", ref.url) +} + +// validateMeasureSource ensures that exactly one way to identify the measure is +// given: the measure-file argument, the --measure-url flag or the --measure-id +// flag. +func validateMeasureSource(args []string, measureUrl string, measureId string) error { + numSources := 0 + if len(args) > 0 { + numSources++ + } + if measureUrl != "" { + numSources++ + } + if measureId != "" { + numSources++ + } + switch numSources { + case 0: + return errors.New("requires a measure-file argument or one of the --measure-url or --measure-id flags") + case 1: + return nil + default: + return errors.New("the measure-file argument and the --measure-url and --measure-id flags are mutually exclusive") + } +} // supportedParameterTypes lists the FHIR primitive types accepted for a CQL // parameter, in the order they are documented to the user. @@ -152,16 +194,19 @@ func buildMeasureParameters(overrides []parsedParameter) ([]fm.ParametersParamet } // evaluateMeasureParameters builds the FHIR Parameters body for the -// $evaluate-measure operation. The given CQL parameters, if any, are added as a +// $evaluate-measure operation. The `measure` input parameter is only needed for +// the type-level operation, because the instance-level one identifies the +// Measure via the URL path. The given CQL parameters, if any, are added as a // nested Parameters resource under the `parameters` input parameter. -func evaluateMeasureParameters(measureUrl string, parameters []fm.ParametersParameter) (fm.Parameters, error) { - body := fm.Parameters{ - Parameter: []fm.ParametersParameter{ - {Name: "measure", ValueString: new(measureUrl)}, - {Name: "periodStart", ValueDate: new("1900")}, - {Name: "periodEnd", ValueDate: new("2200")}, - }, - } +func evaluateMeasureParameters(ref measureRef, parameters []fm.ParametersParameter) (fm.Parameters, error) { + var body fm.Parameters + if ref.url != "" { + body.Parameter = append(body.Parameter, fm.ParametersParameter{Name: "measure", ValueString: new(ref.url)}) + } + body.Parameter = append(body.Parameter, + fm.ParametersParameter{Name: "periodStart", ValueDate: new("1900")}, + fm.ParametersParameter{Name: "periodEnd", ValueDate: new("2200")}, + ) if len(parameters) > 0 { resource, err := json.Marshal(fm.Parameters{Parameter: parameters}) if err != nil { @@ -412,27 +457,35 @@ func handleErrorResponse(resp *http.Response) ([]byte, error) { } } -// newEvaluateMeasureRequest builds the $evaluate-measure request. If CQL -// parameters were given, they can only be transmitted via POST with a -// Parameters body. Otherwise, the operation is invoked via GET. -func newEvaluateMeasureRequest(client *fhir.Client, measureUrl string) (*http.Request, error) { +// newEvaluateMeasureRequest builds the $evaluate-measure request. A measure +// referenced by ID is evaluated via the instance-level operation, one +// referenced by canonical URL via the type-level operation. If CQL parameters +// were given, they can only be transmitted via POST with a Parameters body. +// Otherwise, the operation is invoked via GET. +func newEvaluateMeasureRequest(client *fhir.Client, ref measureRef) (*http.Request, error) { if len(measureParameters) > 0 { - body, err := evaluateMeasureParameters(measureUrl, measureParameters) + body, err := evaluateMeasureParameters(ref, measureParameters) if err != nil { return nil, err } + if ref.id != "" { + return client.NewPostInstanceOperationRequest("Measure", ref.id, "evaluate-measure", !forceSync, body) + } return client.NewPostTypeOperationRequest("Measure", "evaluate-measure", !forceSync, body) } - return client.NewTypeOperationRequest("Measure", "evaluate-measure", !forceSync, - url.Values{ - "measure": []string{measureUrl}, - "periodStart": []string{"1900"}, - "periodEnd": []string{"2200"}, - }) + query := url.Values{ + "periodStart": []string{"1900"}, + "periodEnd": []string{"2200"}, + } + if ref.id != "" { + return client.NewInstanceOperationRequest("Measure", ref.id, "evaluate-measure", !forceSync, query) + } + query.Set("measure", ref.url) + return client.NewTypeOperationRequest("Measure", "evaluate-measure", !forceSync, query) } -func evaluateMeasure(client *fhir.Client, measureUrl string) ([]byte, error) { - req, err := newEvaluateMeasureRequest(client, measureUrl) +func evaluateMeasure(client *fhir.Client, ref measureRef) ([]byte, error) { + req, err := newEvaluateMeasureRequest(client, ref) if err != nil { return nil, err } @@ -462,8 +515,8 @@ func evaluateMeasure(client *fhir.Client, measureUrl string) ([]byte, error) { signal.Notify(interruptChan, os.Interrupt) measureReportBytes, err := client.PollAsyncStatus(contentLocation, interruptChan) if err != nil { - return nil, fmt.Errorf("Error while evaluating the measure with canonical URL %s:\n\n%w", - measureUrl, err) + return nil, fmt.Errorf("Error while evaluating the measure with %s:\n\n%w", + ref.description(), err) } return measureReportBytes, nil default: @@ -471,10 +524,10 @@ func evaluateMeasure(client *fhir.Client, measureUrl string) ([]byte, error) { } } -func evaluateMeasureWithRetry(client *fhir.Client, measureUrl string) ([]byte, error) { +func evaluateMeasureWithRetry(client *fhir.Client, ref measureRef) ([]byte, error) { var lastErr error for wait := 100 * time.Millisecond; wait < 5*time.Second; wait *= 2 { - measureReport, err := evaluateMeasure(client, measureUrl) + measureReport, err := evaluateMeasure(client, ref) lastErr = err if !isRetryable(errors.Unwrap(err)) { return measureReport, err @@ -485,22 +538,112 @@ func evaluateMeasureWithRetry(client *fhir.Client, measureUrl string) ([]byte, e return nil, lastErr } +// createMeasureResources reads the measure file, creates the Library and +// Measure resources with random canonical URLs on the server and returns a +// reference to the created Measure. +func createMeasureResources(client *fhir.Client, measureFile string) (measureRef, error) { + m, err := readMeasureFile(measureFile) + if err != nil { + return measureRef{}, err + } + + measureUrl, err := RandomUrl() + if err != nil { + return measureRef{}, err + } + + libraryUrl, err := RandomUrl() + if err != nil { + return measureRef{}, err + } + + measure, err := CreateMeasureResource(*m, measureUrl, libraryUrl) + if err != nil { + return measureRef{}, fmt.Errorf("error while reading the measure file: %v", err) + } + + library, err := CreateLibraryResource(*m, libraryUrl) + if err != nil { + return measureRef{}, err + } + + measureBytes, err := json.Marshal(measure) + if err != nil { + return measureRef{}, err + } + + libraryBytes, err := json.Marshal(library) + if err != nil { + return measureRef{}, err + } + + bundle := fm.Bundle{ + Type: fm.BundleTypeTransaction, + Entry: []fm.BundleEntry{ + createBundleEntry("Library", libraryBytes), + createBundleEntry("Measure", measureBytes), + }, + } + + bundleBytes, err := json.Marshal(bundle) + if err != nil { + return measureRef{}, err + } + + req, err := client.NewTransactionRequest(bytes.NewReader(bundleBytes)) + if err != nil { + return measureRef{}, err + } + + resp, err := client.Do(req) + if err != nil { + return measureRef{}, err + } + defer resp.Body.Close() + + if resp.StatusCode == 200 { + if _, err := io.Copy(io.Discard, resp.Body); err != nil { + return measureRef{}, err + } + } else { + if _, err := io.ReadAll(resp.Body); err != nil { + return measureRef{}, err + } + return measureRef{}, fmt.Errorf("can't create the Measure and/or Library Resource") + } + + return measureRef{url: measureUrl}, nil +} + var evaluateMeasureCmd = &cobra.Command{ Use: "evaluate-measure [measure-file]", Short: "Evaluates a Measure", - Long: `Given a measure in YAML form, creates the required FHIR resources, + Long: `Given a measure in YAML form, creates the required FHIR resources, evaluates that measure and returns the measure report. +Instead of a measure file, an existing Measure on the server can be referenced +by its canonical URL with --measure-url or by its resource ID with +--measure-id. In that case no resources are created. + Examples: blazectl evaluate-measure --server "http://localhost:8080/fhir" stratifier-condition-code.yml blazectl evaluate-measure --server "http://localhost:8080/fhir" \ --parameter Gender=male --parameter MinAge:integer=18 gender-age.yml + blazectl evaluate-measure --server "http://localhost:8080/fhir" \ + --measure-url "https://example.com/fhir/Measure/example" + + blazectl evaluate-measure --server "http://localhost:8080/fhir" \ + --measure-id DACG22F3LKPU7ZZ5 + See: https://github.com/samply/blaze/blob/main/docs/cql-queries/blazectl.md`, Args: func(cmd *cobra.Command, args []string) error { - if len(args) < 1 { - return errors.New("requires a measure-file argument") + if err := validateMeasureSource(args, existingMeasureUrl, existingMeasureId); err != nil { + return err + } + if len(args) == 0 { + return nil } if info, err := os.Stat(args[0]); os.IsNotExist(err) { return fmt.Errorf("measure file `%s` doesn't exist", args[0]) @@ -511,12 +654,6 @@ See: https://github.com/samply/blaze/blob/main/docs/cql-queries/blazectl.md`, } }, RunE: func(cmd *cobra.Command, args []string) error { - m, err := readMeasureFile(args[0]) - if err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } - overrides, err := parseParameterOverrides(rawMeasureParameters) if err != nil { fmt.Fprintln(os.Stderr, err) @@ -529,93 +666,24 @@ See: https://github.com/samply/blaze/blob/main/docs/cql-queries/blazectl.md`, os.Exit(1) } - measureUrl, err := RandomUrl() - if err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } - - libraryUrl, err := RandomUrl() - if err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } - - measure, err := CreateMeasureResource(*m, measureUrl, libraryUrl) - if err != nil { - fmt.Fprintf(os.Stderr, "error while reading the measure file: %v\n", err) - os.Exit(1) - } - - library, err := CreateLibraryResource(*m, libraryUrl) - if err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } - - measureBytes, err := json.Marshal(measure) - if err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } - - libraryBytes, err := json.Marshal(library) - if err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } - - bundle := fm.Bundle{ - Type: fm.BundleTypeTransaction, - Entry: []fm.BundleEntry{ - createBundleEntry("Library", libraryBytes), - createBundleEntry("Measure", measureBytes), - }, - } - - bundleBytes, err := json.Marshal(bundle) - if err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } - err = createClient() if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } - req, err := client.NewTransactionRequest(bytes.NewReader(bundleBytes)) - if err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } - - resp, err := client.Do(req) - if err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } - defer resp.Body.Close() - - if resp.StatusCode == 200 { - _, err := io.Copy(io.Discard, resp.Body) - if err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } - } else { - _, err := io.ReadAll(resp.Body) + ref := measureRef{url: existingMeasureUrl, id: existingMeasureId} + if len(args) > 0 { + ref, err = createMeasureResources(client, args[0]) if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } - return fmt.Errorf("can't create the Measure and/or Library Resource") } - fmt.Fprintf(os.Stderr, "Evaluate measure with canonical URL %s on %s ...\n\n", measureUrl, server) + fmt.Fprintf(os.Stderr, "Evaluate measure with %s on %s ...\n\n", ref.description(), server) - measureReport, err := evaluateMeasureWithRetry(client, measureUrl) + measureReport, err := evaluateMeasureWithRetry(client, ref) if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) @@ -632,6 +700,10 @@ func init() { evaluateMeasureCmd.Flags().StringVar(&server, "server", "", "the base URL of the server to use") evaluateMeasureCmd.Flags().BoolVarP(&forceSync, "force-sync", "", false, "force synchronous responses") + evaluateMeasureCmd.Flags().StringVar(&existingMeasureUrl, "measure-url", "", + "the canonical URL of an existing Measure to evaluate instead of a measure file") + evaluateMeasureCmd.Flags().StringVar(&existingMeasureId, "measure-id", "", + "the resource ID of an existing Measure to evaluate instead of a measure file") evaluateMeasureCmd.Flags().StringArrayVarP(&rawMeasureParameters, "parameter", "p", nil, "set the value of a CQL parameter, in the form name=value or name:type=value "+ "(supported types: "+supportedParameterTypes+"; the type defaults to string; "+ diff --git a/cmd/evalueMeasure_test.go b/cmd/evalueMeasure_test.go index 46d8832..b04cdef 100644 --- a/cmd/evalueMeasure_test.go +++ b/cmd/evalueMeasure_test.go @@ -537,7 +537,7 @@ func TestBuildMeasureParameters(t *testing.T) { func TestEvaluateMeasureParameters(t *testing.T) { t.Run("without CQL parameters", func(t *testing.T) { - params, err := evaluateMeasureParameters("measure-url", nil) + params, err := evaluateMeasureParameters(measureRef{url: "measure-url"}, nil) assert.Nil(t, err) assert.Equal(t, 3, len(params.Parameter)) assert.Equal(t, "measure", params.Parameter[0].Name) @@ -548,8 +548,18 @@ func TestEvaluateMeasureParameters(t *testing.T) { assert.Equal(t, "2200", *params.Parameter[2].ValueDate) }) + t.Run("a measure referenced by ID omits the measure parameter", func(t *testing.T) { + params, err := evaluateMeasureParameters(measureRef{id: "some-id"}, nil) + assert.Nil(t, err) + assert.Equal(t, 2, len(params.Parameter)) + assert.Equal(t, "periodStart", params.Parameter[0].Name) + assert.Equal(t, "1900", *params.Parameter[0].ValueDate) + assert.Equal(t, "periodEnd", params.Parameter[1].Name) + assert.Equal(t, "2200", *params.Parameter[1].ValueDate) + }) + t.Run("with CQL parameters as a nested Parameters resource", func(t *testing.T) { - params, err := evaluateMeasureParameters("measure-url", + params, err := evaluateMeasureParameters(measureRef{url: "measure-url"}, []fm.ParametersParameter{{Name: "Foo", ValueString: new("bar")}}) assert.Nil(t, err) assert.Equal(t, 4, len(params.Parameter)) @@ -563,13 +573,61 @@ func TestEvaluateMeasureParameters(t *testing.T) { }) } +func TestMeasureRefDescription(t *testing.T) { + t.Run("by canonical URL", func(t *testing.T) { + assert.Equal(t, "canonical URL measure-url", measureRef{url: "measure-url"}.description()) + }) + + t.Run("by ID", func(t *testing.T) { + assert.Equal(t, "ID some-id", measureRef{id: "some-id"}.description()) + }) +} + +func TestValidateMeasureSource(t *testing.T) { + t.Run("no measure given", func(t *testing.T) { + err := validateMeasureSource(nil, "", "") + assert.Error(t, err) + assert.Contains(t, err.Error(), "requires a measure-file argument or one of the --measure-url or --measure-id flags") + }) + + t.Run("measure file only", func(t *testing.T) { + assert.Nil(t, validateMeasureSource([]string{"measure.yml"}, "", "")) + }) + + t.Run("measure URL only", func(t *testing.T) { + assert.Nil(t, validateMeasureSource(nil, "measure-url", "")) + }) + + t.Run("measure ID only", func(t *testing.T) { + assert.Nil(t, validateMeasureSource(nil, "", "some-id")) + }) + + t.Run("measure file and URL", func(t *testing.T) { + err := validateMeasureSource([]string{"measure.yml"}, "measure-url", "") + assert.Error(t, err) + assert.Contains(t, err.Error(), "mutually exclusive") + }) + + t.Run("measure file and ID", func(t *testing.T) { + err := validateMeasureSource([]string{"measure.yml"}, "", "some-id") + assert.Error(t, err) + assert.Contains(t, err.Error(), "mutually exclusive") + }) + + t.Run("measure URL and ID", func(t *testing.T) { + err := validateMeasureSource(nil, "measure-url", "some-id") + assert.Error(t, err) + assert.Contains(t, err.Error(), "mutually exclusive") + }) +} + func TestEvaluateMeasure(t *testing.T) { t.Run("Request to FHIR server fails", func(t *testing.T) { baseURL, _ := url.ParseRequestURI("http://localhost") client := fhir.NewClient(*baseURL, nil) - _, err := evaluateMeasure(client, "foo") + _, err := evaluateMeasure(client, measureRef{url: "foo"}) assert.Error(t, err) }) @@ -583,7 +641,7 @@ func TestEvaluateMeasure(t *testing.T) { baseURL, _ := url.ParseRequestURI(server.URL) client := fhir.NewClient(*baseURL, nil) - measureReport, _ := evaluateMeasure(client, "foo") + measureReport, _ := evaluateMeasure(client, measureRef{url: "foo"}) assert.Equal(t, 0, len(measureReport)) }) @@ -601,7 +659,7 @@ func TestEvaluateMeasure(t *testing.T) { baseURL, _ := url.ParseRequestURI(server.URL) client := fhir.NewClient(*baseURL, nil) - measureReport, err := evaluateMeasure(client, "foo") + measureReport, err := evaluateMeasure(client, measureRef{url: "foo"}) assert.Nil(t, err) assert.Equal(t, `{"resourceType":"MeasureReport"}`, string(measureReport)) @@ -638,7 +696,7 @@ func TestEvaluateMeasure(t *testing.T) { baseURL, _ := url.ParseRequestURI(server.URL) client := fhir.NewClient(*baseURL, nil) - measureReport, err := evaluateMeasure(client, "foo") + measureReport, err := evaluateMeasure(client, measureRef{url: "foo"}) assert.Nil(t, err) assert.Equal(t, `{"resourceType":"MeasureReport"}`, string(measureReport)) @@ -681,12 +739,93 @@ func TestEvaluateMeasure(t *testing.T) { baseURL, _ := url.ParseRequestURI(server.URL) client := fhir.NewClient(*baseURL, nil) - measureReport, err := evaluateMeasure(client, "foo") + measureReport, err := evaluateMeasure(client, measureRef{url: "foo"}) assert.Nil(t, err) assert.Equal(t, `{"resourceType":"MeasureReport","id":"123"}`, string(measureReport)) }) + t.Run("a measure referenced by ID uses the instance-level operation", func(t *testing.T) { + var capturedPath string + var capturedQuery url.Values + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedPath = r.URL.Path + capturedQuery = r.URL.Query() + w.Header().Set(fhir.HeaderContentType, fhir.MediaTypeFhirJson) + w.WriteHeader(http.StatusOK) + if _, err := w.Write([]byte(`{"resourceType":"MeasureReport"}`)); err != nil { + t.Error(err) + } + })) + defer server.Close() + + baseURL, _ := url.ParseRequestURI(server.URL) + client := fhir.NewClient(*baseURL, nil) + + measureReport, err := evaluateMeasure(client, measureRef{id: "some-id"}) + + assert.Nil(t, err) + assert.Equal(t, `{"resourceType":"MeasureReport"}`, string(measureReport)) + assert.Equal(t, "/Measure/some-id/$evaluate-measure", capturedPath) + assert.Empty(t, capturedQuery.Get("measure")) + assert.Equal(t, "1900", capturedQuery.Get("periodStart")) + assert.Equal(t, "2200", capturedQuery.Get("periodEnd")) + }) + + t.Run("with CQL parameters a measure referenced by ID is posted without a measure parameter", func(t *testing.T) { + var capturedMethod string + var capturedPath string + var capturedBody []byte + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedMethod = r.Method + capturedPath = r.URL.Path + capturedBody, _ = io.ReadAll(r.Body) + w.Header().Set(fhir.HeaderContentType, fhir.MediaTypeFhirJson) + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + baseURL, _ := url.ParseRequestURI(server.URL) + client := fhir.NewClient(*baseURL, nil) + + measureParameters = []fm.ParametersParameter{{Name: "Foo", ValueString: new("bar")}} + defer func() { measureParameters = nil }() + + _, err := evaluateMeasure(client, measureRef{id: "some-id"}) + assert.Nil(t, err) + assert.Equal(t, "POST", capturedMethod) + assert.Equal(t, "/Measure/some-id/$evaluate-measure", capturedPath) + + params, err := fm.UnmarshalParameters(capturedBody) + assert.Nil(t, err) + assert.Equal(t, 3, len(params.Parameter)) + assert.Equal(t, "periodStart", params.Parameter[0].Name) + assert.Equal(t, "periodEnd", params.Parameter[1].Name) + assert.Equal(t, "parameters", params.Parameter[2].Name) + }) + + t.Run("async error response with a measure referenced by ID", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/Measure/some-id/$evaluate-measure": + w.Header().Set("Content-Location", fmt.Sprintf("http://%s/async-poll", r.Host)) + w.WriteHeader(http.StatusAccepted) + case "/async-poll": + w.WriteHeader(http.StatusOK) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + baseURL, _ := url.ParseRequestURI(server.URL) + client := fhir.NewClient(*baseURL, nil) + + _, err := evaluateMeasure(client, measureRef{id: "some-id"}) + + assert.Equal(t, "Error while evaluating the measure with ID some-id:\n\nnon FHIR response", err.Error()) + }) + t.Run("with CQL parameters the request is posted", func(t *testing.T) { var capturedMethod string var capturedBody []byte @@ -704,7 +843,7 @@ func TestEvaluateMeasure(t *testing.T) { measureParameters = []fm.ParametersParameter{{Name: "Foo", ValueString: new("bar")}} defer func() { measureParameters = nil }() - _, err := evaluateMeasure(client, "measure-url") + _, err := evaluateMeasure(client, measureRef{url: "measure-url"}) assert.Nil(t, err) assert.Equal(t, "POST", capturedMethod) @@ -735,7 +874,7 @@ func TestEvaluateMeasure(t *testing.T) { baseURL, _ := url.ParseRequestURI(server.URL) client := fhir.NewClient(*baseURL, nil) - _, err := evaluateMeasure(client, "foo") + _, err := evaluateMeasure(client, measureRef{url: "foo"}) assert.Contains(t, err.Error(), "An element or header value is invalid.") }) @@ -761,7 +900,7 @@ func TestEvaluateMeasure(t *testing.T) { baseURL, _ := url.ParseRequestURI(server.URL) client := fhir.NewClient(*baseURL, nil) - _, err := evaluateMeasure(client, "foo") + _, err := evaluateMeasure(client, measureRef{url: "foo"}) assert.True(t, isRetryable(errors.Unwrap(err))) }) @@ -791,7 +930,7 @@ func TestEvaluateMeasure(t *testing.T) { baseURL, _ := url.ParseRequestURI(server.URL) client := fhir.NewClient(*baseURL, nil) - measureReport, err := evaluateMeasureWithRetry(client, "foo") + measureReport, err := evaluateMeasureWithRetry(client, measureRef{url: "foo"}) assert.Equal(t, 0, len(measureReport)) assert.Nil(t, err) @@ -818,7 +957,7 @@ func TestEvaluateMeasure(t *testing.T) { baseURL, _ := url.ParseRequestURI(server.URL) client := fhir.NewClient(*baseURL, nil) - _, err := evaluateMeasureWithRetry(client, "foo") + _, err := evaluateMeasureWithRetry(client, measureRef{url: "foo"}) assert.Contains(t, err.Error(), "An internal timeout has occurred.") }) @@ -841,7 +980,7 @@ func TestEvaluateMeasure(t *testing.T) { baseURL, _ := url.ParseRequestURI(server.URL) client := fhir.NewClient(*baseURL, nil) - _, err := evaluateMeasure(client, "foo") + _, err := evaluateMeasure(client, measureRef{url: "foo"}) assert.Equal(t, "Error while evaluating the measure with canonical URL foo:\n\nnon FHIR response", err.Error()) }) @@ -886,7 +1025,7 @@ func TestEvaluateMeasure(t *testing.T) { baseURL, _ := url.ParseRequestURI(server.URL) client := fhir.NewClient(*baseURL, nil) - measureReport, err := evaluateMeasure(client, "foo") + measureReport, err := evaluateMeasure(client, measureRef{url: "foo"}) assert.Equal(t, 0, len(measureReport)) assert.Nil(t, err) diff --git a/fhir/client.go b/fhir/client.go index 30caf21..e4d1ea0 100644 --- a/fhir/client.go +++ b/fhir/client.go @@ -244,6 +244,11 @@ func (c *Client) NewPostTypeOperationRequest(resourceType string, operationName return c.newPostOperationRequest(c.baseURL.JoinPath(resourceType, "$"+operationName).String(), async, parameters) } +// NewPostInstanceOperationRequest creates a new instance-level operation request that will use POST with parameters. +func (c *Client) NewPostInstanceOperationRequest(resourceType string, resourceId string, operationName string, async bool, parameters fm.Parameters) (*http.Request, error) { + return c.newPostOperationRequest(c.baseURL.JoinPath(resourceType, resourceId, "$"+operationName).String(), async, parameters) +} + // NewHistorySystemRequest creates a new history system interaction request that will use GET on a // FHIR history endpoint. func (c *Client) NewHistorySystemRequest() (*http.Request, error) { @@ -256,11 +261,11 @@ func (c *Client) NewHistorySystemRequest() (*http.Request, error) { return req, nil } -// NewTypeOperationRequest creates a new operation request that will use GET with parameters in the query params of the URL. -func (c *Client) NewTypeOperationRequest(resourceType string, operationName string, async bool, parameters url.Values) (*http.Request, error) { - _url := c.baseURL.JoinPath(resourceType, "$"+operationName) - _url.RawQuery = parameters.Encode() - req, err := http.NewRequest("GET", _url.String(), nil) +// newGetOperationRequest creates a new operation request that will use GET with +// parameters in the query params of the given operation URL. +func (c *Client) newGetOperationRequest(operationUrl *url.URL, async bool, parameters url.Values) (*http.Request, error) { + operationUrl.RawQuery = parameters.Encode() + req, err := http.NewRequest("GET", operationUrl.String(), nil) if err != nil { return nil, err } @@ -271,6 +276,16 @@ func (c *Client) NewTypeOperationRequest(resourceType string, operationName stri return req, nil } +// NewTypeOperationRequest creates a new operation request that will use GET with parameters in the query params of the URL. +func (c *Client) NewTypeOperationRequest(resourceType string, operationName string, async bool, parameters url.Values) (*http.Request, error) { + return c.newGetOperationRequest(c.baseURL.JoinPath(resourceType, "$"+operationName), async, parameters) +} + +// NewInstanceOperationRequest creates a new instance-level operation request that will use GET with parameters in the query params of the URL. +func (c *Client) NewInstanceOperationRequest(resourceType string, resourceId string, operationName string, async bool, parameters url.Values) (*http.Request, error) { + return c.newGetOperationRequest(c.baseURL.JoinPath(resourceType, resourceId, "$"+operationName), async, parameters) +} + // Do calls Do on the HTTP client of the FHIR client. func (c *Client) Do(req *http.Request) (*http.Response, error) { if c.auth != nil { diff --git a/fhir/client_test.go b/fhir/client_test.go index b4502d4..fffb456 100644 --- a/fhir/client_test.go +++ b/fhir/client_test.go @@ -184,6 +184,37 @@ func TestNewAsyncTypeOperationRequest(t *testing.T) { assert.Equal(t, MediaTypeFhirJson, req.Header.Get(HeaderAccept)) } +func TestNewInstanceOperationRequest(t *testing.T) { + parsedUrl, _ := url.ParseRequestURI("http://localhost:8080/some-path") + client := NewClient(*parsedUrl, nil) + + parameters, _ := url.ParseQuery("") + + t.Run("synchronous request", func(t *testing.T) { + req, err := client.NewInstanceOperationRequest("some-type", "some-id", "some-operation", false, parameters) + if err != nil { + t.Fatalf("could not create an instance operation request: %v", err) + } + + assert.Equal(t, "GET", req.Method) + assert.Equal(t, "/some-path/some-type/some-id/$some-operation", req.URL.Path) + assert.Equal(t, MediaTypeFhirJson, req.Header.Get(HeaderAccept)) + assert.Equal(t, "", req.Header.Get("Prefer")) + }) + + t.Run("asynchronous request", func(t *testing.T) { + req, err := client.NewInstanceOperationRequest("some-type", "some-id", "some-operation", true, parameters) + if err != nil { + t.Fatalf("could not create an async instance operation request: %v", err) + } + + assert.Equal(t, "GET", req.Method) + assert.Equal(t, "/some-path/some-type/some-id/$some-operation", req.URL.Path) + assert.Equal(t, MediaTypeFhirJson, req.Header.Get(HeaderAccept)) + assert.Equal(t, "respond-async", req.Header.Get("Prefer")) + }) +} + func TestClientSecurity(t *testing.T) { crt, key, err := createSelfSignedCertificate() if err != nil { @@ -416,6 +447,58 @@ func TestNewPostTypeOperationRequest(t *testing.T) { }) } +func TestNewPostInstanceOperationRequest(t *testing.T) { + parsedUrl, _ := url.ParseRequestURI("http://localhost:8080/some-path") + client := NewClient(*parsedUrl, nil) + + testValue := "test-value" + parameters := fm.Parameters{ + Parameter: []fm.ParametersParameter{ + { + Name: "test-param", + ValueString: &testValue, + }, + }, + } + + t.Run("synchronous request", func(t *testing.T) { + req, err := client.NewPostInstanceOperationRequest("some-type", "some-id", "some-operation", false, parameters) + if err != nil { + t.Fatalf("could not create an instance operation request: %v", err) + } + + assert.Equal(t, "POST", req.Method) + assert.Equal(t, "/some-path/some-type/some-id/$some-operation", req.URL.Path) + assert.Equal(t, MediaTypeFhirJson, req.Header.Get(HeaderAccept)) + assert.Equal(t, MediaTypeFhirJson, req.Header.Get(HeaderContentType)) + assert.Equal(t, "", req.Header.Get("Prefer")) + + // Verify request body contains the parameters + body, err := io.ReadAll(req.Body) + assert.Nil(t, err) + var decodedParams fm.Parameters + err = json.Unmarshal(body, &decodedParams) + assert.Nil(t, err) + assert.Equal(t, 1, len(decodedParams.Parameter)) + assert.Equal(t, "test-param", decodedParams.Parameter[0].Name) + assert.NotNil(t, decodedParams.Parameter[0].ValueString) + assert.Equal(t, "test-value", *decodedParams.Parameter[0].ValueString) + }) + + t.Run("asynchronous request", func(t *testing.T) { + req, err := client.NewPostInstanceOperationRequest("some-type", "some-id", "some-operation", true, parameters) + if err != nil { + t.Fatalf("could not create an async instance operation request: %v", err) + } + + assert.Equal(t, "POST", req.Method) + assert.Equal(t, "/some-path/some-type/some-id/$some-operation", req.URL.Path) + assert.Equal(t, MediaTypeFhirJson, req.Header.Get(HeaderAccept)) + assert.Equal(t, MediaTypeFhirJson, req.Header.Get(HeaderContentType)) + assert.Equal(t, "respond-async", req.Header.Get("Prefer")) + }) +} + func TestNewHistorySystemRequest(t *testing.T) { parsedUrl, _ := url.ParseRequestURI("http://localhost:8080/some-path") client := NewClient(*parsedUrl, nil)