dev/vendor/MANIFEST.toml pins bcda-app@7decdcc (v3 handler + tests, request validation middleware, OperationOutcome writer, v3 Postman collection, gzip fixtures, sandbox config, LICENSE) and the bcda.cms.gov v3 data dictionary, v2 dictionary and official v3 sample ndjson. vendor_bcda_app.py --verify | --check | --sync (stdlib only); the bcda-vendor workflow runs --check weekly and files an issue on drift — nothing auto-commits, a payload change upstream is something a human reads before the flattener is re-pointed. gen_bcda_v3_crosswalk.py writes dev/seeds/bcda_v3_data_dictionary.csv (fhirPath, IDR sourceColumn, ccwMapping, cclfMapping per v3 field), bcda_v3_cclf_crosswalk.csv (293 CCLF elements -> v3 field) and bcda_v3_cclf_absent.csv (52 CCLF fields v3 does not carry); the CCLF sheet has label/name swapped on data rows and is normalised. Tests assert the committed seeds match the vendored workbook. refs #1139
417 lines
12 KiB
Go
417 lines
12 KiB
Go
package middleware
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/CMSgov/bcda-app/bcda/constants"
|
|
"github.com/CMSgov/bcda-app/bcda/fhir"
|
|
responseutils "github.com/CMSgov/bcda-app/bcda/responseutils"
|
|
responseutilsv2 "github.com/CMSgov/bcda-app/bcda/responseutils/v2"
|
|
responseutilsv3 "github.com/CMSgov/bcda-app/bcda/responseutils/v3"
|
|
"github.com/CMSgov/bcda-app/log"
|
|
"github.com/sirupsen/logrus"
|
|
)
|
|
|
|
var supportedOutputFormats = map[string]struct{}{
|
|
"ndjson": {},
|
|
"application/fhir+ndjson": {},
|
|
"application/ndjson": {}}
|
|
|
|
type RequestParameters struct {
|
|
Since time.Time
|
|
ResourceTypes []string
|
|
Version string // e.g. v1, v2
|
|
RequestURL string
|
|
TypeFilter fhir.TypeFilterSubquery
|
|
}
|
|
|
|
// requestkey is an unexported context key to avoid collisions
|
|
type requestkey int
|
|
|
|
const rk requestkey = 0
|
|
|
|
// TODO: replace this function else where with line 35
|
|
func SetRequestParamsCtx(ctx context.Context, rp RequestParameters) context.Context {
|
|
return context.WithValue(ctx, rk, rp)
|
|
}
|
|
|
|
func GetRequestParamsFromCtx(ctx context.Context) (RequestParameters, bool) {
|
|
rp, ok := ctx.Value(rk).(RequestParameters)
|
|
return rp, ok
|
|
}
|
|
|
|
func validateOutputFormat(r *http.Request, rw fhirResponseWriter, w http.ResponseWriter) bool {
|
|
ctx := r.Context()
|
|
params, ok := r.URL.Query()["_outputFormat"]
|
|
if !ok {
|
|
return true
|
|
}
|
|
|
|
if _, found := supportedOutputFormats[params[0]]; !found {
|
|
errMsg := fmt.Sprintf("_outputFormat parameter must be one of %v", getKeys(supportedOutputFormats))
|
|
ctx, _ = log.WriteWarnWithFields(
|
|
ctx,
|
|
fmt.Sprintf("%s: %s", responseutils.FormatErr, errMsg),
|
|
logrus.Fields{"resp_status": http.StatusBadRequest},
|
|
)
|
|
rw.OpOutcome(ctx, w, http.StatusBadRequest, responseutils.FormatErr, errMsg)
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// we do not support "_elements" parameter
|
|
func validateElementsParameter(r *http.Request, rw fhirResponseWriter, w http.ResponseWriter) bool {
|
|
_, ok := r.URL.Query()["_elements"]
|
|
ctx := r.Context()
|
|
if !ok {
|
|
return true
|
|
}
|
|
|
|
errMsg := "Invalid parameter: this server does not support the _elements parameter."
|
|
ctx, _ = log.WriteWarnWithFields(
|
|
ctx,
|
|
fmt.Sprintf("%s: %s", responseutils.RequestErr, errMsg),
|
|
logrus.Fields{"resp_status": http.StatusBadRequest},
|
|
)
|
|
rw.OpOutcome(ctx, w, http.StatusBadRequest, responseutils.RequestErr, errMsg)
|
|
return false
|
|
}
|
|
|
|
// Check and see if the user has a duplicated the query parameter symbol (?)
|
|
// e.g. /api/v1/Patient/$export?_type=ExplanationOfBenefit&?_since=2020-09-13T08:00:00.000-05:00
|
|
func validateQueryParameterFormat(r *http.Request, rw fhirResponseWriter, w http.ResponseWriter) bool {
|
|
ctx := r.Context()
|
|
|
|
for key := range r.URL.Query() {
|
|
if strings.HasPrefix(key, "?") {
|
|
errMsg := "Invalid parameter: query parameters cannot start with ?"
|
|
ctx, _ = log.WriteWarnWithFields(
|
|
ctx,
|
|
fmt.Sprintf("%s: %s", responseutils.FormatErr, errMsg),
|
|
logrus.Fields{"resp_status": http.StatusBadRequest},
|
|
)
|
|
rw.OpOutcome(ctx, w, http.StatusBadRequest, responseutils.FormatErr, errMsg)
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func validateSinceParameter(r *http.Request, rw fhirResponseWriter, w http.ResponseWriter) (time.Time, bool) {
|
|
ctx := r.Context()
|
|
params, ok := r.URL.Query()["_since"]
|
|
if !ok {
|
|
return time.Time{}, true
|
|
}
|
|
|
|
sinceDate, err := time.Parse(time.RFC3339Nano, params[0])
|
|
if err != nil {
|
|
errMsg := "Invalid date format supplied in _since parameter. Date must be in FHIR Instant format."
|
|
ctx, _ = log.WriteWarnWithFields(
|
|
ctx,
|
|
fmt.Sprintf("%s: %s", responseutils.FormatErr, errMsg),
|
|
logrus.Fields{"resp_status": http.StatusBadRequest},
|
|
)
|
|
rw.OpOutcome(ctx, w, http.StatusBadRequest, responseutils.FormatErr, errMsg)
|
|
return time.Time{}, false
|
|
}
|
|
|
|
if sinceDate.After(time.Now()) {
|
|
errMsg := "Invalid date format supplied in _since parameter. Date must be a date that has already passed"
|
|
ctx, _ = log.WriteWarnWithFields(
|
|
ctx,
|
|
fmt.Sprintf("%s: %s", responseutils.FormatErr, errMsg),
|
|
logrus.Fields{"resp_status": http.StatusBadRequest},
|
|
)
|
|
rw.OpOutcome(ctx, w, http.StatusBadRequest, responseutils.FormatErr, errMsg)
|
|
return time.Time{}, false
|
|
}
|
|
|
|
return sinceDate, true
|
|
}
|
|
|
|
func validateResourceTypes(r *http.Request, rw fhirResponseWriter, w http.ResponseWriter) ([]string, bool) {
|
|
ctx := r.Context()
|
|
params, ok := r.URL.Query()["_type"]
|
|
if !ok {
|
|
return nil, true
|
|
}
|
|
|
|
// validate no duplicate resource types
|
|
resourceMap := make(map[string]struct{})
|
|
resourceTypes := strings.Split(params[0], ",")
|
|
for _, resource := range resourceTypes {
|
|
if _, ok := resourceMap[resource]; !ok {
|
|
resourceMap[resource] = struct{}{}
|
|
} else {
|
|
errMsg := fmt.Sprintf("Repeated resource type %s", resource)
|
|
ctx, _ = log.WriteWarnWithFields(
|
|
ctx,
|
|
fmt.Sprintf("%s: %s", responseutils.RequestErr, errMsg),
|
|
logrus.Fields{"resp_status": http.StatusBadRequest},
|
|
)
|
|
rw.OpOutcome(ctx, w, http.StatusBadRequest, responseutils.RequestErr, errMsg)
|
|
return nil, false
|
|
}
|
|
}
|
|
return resourceTypes, true
|
|
}
|
|
|
|
// validateTypeFilterParameter validates the contents of the typeFilter param.
|
|
func validateTypeFilterParameter(r *http.Request, rw fhirResponseWriter, w http.ResponseWriter, version string) (fhir.TypeFilterSubquery, bool) {
|
|
var typeFilterParam fhir.TypeFilterSubquery
|
|
ctx := r.Context()
|
|
|
|
params, ok := r.URL.Query()["_typeFilter"]
|
|
if version != constants.V3Version || !ok {
|
|
return typeFilterParam, true
|
|
}
|
|
|
|
typeFilterParams, err := GetTypeFilterSubquery(params)
|
|
if err != nil {
|
|
ctx, _ = log.WriteWarnWithFields(
|
|
ctx,
|
|
fmt.Sprintf("%s: %s", responseutils.RequestErr, err.Error()),
|
|
logrus.Fields{"resp_status": http.StatusBadRequest},
|
|
)
|
|
rw.OpOutcome(ctx, w, http.StatusBadRequest, responseutils.RequestErr, err.Error())
|
|
return typeFilterParam, false
|
|
}
|
|
return typeFilterParams, true
|
|
}
|
|
|
|
// GetTypeFilterSubquery parses the _typeFilter subquery
|
|
// For _tag, it validates each comma-separated token to correctly resolve compound query filters.
|
|
func GetTypeFilterSubquery(params []string) (fhir.TypeFilterSubquery, error) {
|
|
var subquery fhir.TypeFilterSubquery
|
|
|
|
// If more than one _typeFilter param (a logical "or"), return an error, we do not support that yet
|
|
if len(params) > 1 {
|
|
return subquery, fhir.ParameterValidationError{Details: "failed to process request given more that one _typeFilter parameter"}
|
|
}
|
|
|
|
subquery, err := fhir.ParseTypeFilterSubquery(params[0])
|
|
if err != nil {
|
|
return subquery, err
|
|
}
|
|
|
|
err = fhir.ValidateTypeFilterSubquery(subquery)
|
|
if err != nil {
|
|
return subquery, err
|
|
}
|
|
|
|
return subquery, nil
|
|
}
|
|
|
|
func HasSystemTypeTag(subquery fhir.TypeFilterSubquery) bool {
|
|
tagParams, _ := fhir.GetTagParams(subquery)
|
|
for _, tagParam := range tagParams {
|
|
for _, tagValue := range tagParam.Values {
|
|
if tagValue.System == constants.BFDSystemTypeURL {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// ValidateRequestURL ensure that request matches certain expectations.
|
|
// Any error that it finds will result in a http.StatusBadRequest response.
|
|
// If successful, it populates the request context with RequestParameters that can be used downstream.
|
|
// These paramters can be retrieved by calling RequestParametersFromContext.
|
|
func ValidateRequestURL(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
rw, version := getResponseWriterFromRequestPath(w, r)
|
|
if rw == nil {
|
|
return
|
|
}
|
|
|
|
// Validate all parameters
|
|
if !validateOutputFormat(r, rw, w) ||
|
|
!validateElementsParameter(r, rw, w) ||
|
|
!validateQueryParameterFormat(r, rw, w) {
|
|
return
|
|
}
|
|
|
|
// Validate _since parameter
|
|
sinceDate, valid := validateSinceParameter(r, rw, w)
|
|
if !valid {
|
|
return
|
|
}
|
|
|
|
// Validate resource types
|
|
resourceTypes, valid := validateResourceTypes(r, rw, w)
|
|
if !valid {
|
|
return
|
|
}
|
|
|
|
// Validate type filter for v3
|
|
typeFilter, valid := validateTypeFilterParameter(r, rw, w, version)
|
|
if !valid {
|
|
return
|
|
}
|
|
|
|
// Build request parameters
|
|
rp := RequestParameters{
|
|
Version: version,
|
|
RequestURL: r.URL.String(),
|
|
Since: sinceDate,
|
|
ResourceTypes: resourceTypes,
|
|
TypeFilter: typeFilter,
|
|
}
|
|
|
|
ctx := SetRequestParamsCtx(r.Context(), rp)
|
|
next.ServeHTTP(w, r.WithContext(ctx))
|
|
})
|
|
}
|
|
|
|
func ValidateRequestHeaders(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
h := r.Header
|
|
|
|
rw, _ := getResponseWriterFromRequestPath(w, r)
|
|
if rw == nil {
|
|
return
|
|
}
|
|
|
|
acceptValues := parseHeaderValues(h, "Accept")
|
|
if len(acceptValues) == 0 {
|
|
ctx, _ = log.WriteWarnWithFields(
|
|
ctx,
|
|
fmt.Sprintf("%s: Accept header is required", responseutils.FormatErr),
|
|
logrus.Fields{"resp_status": http.StatusBadRequest},
|
|
)
|
|
rw.OpOutcome(ctx, w, http.StatusBadRequest, responseutils.FormatErr, "Accept header is required")
|
|
return
|
|
}
|
|
|
|
if !hasHeaderToken(acceptValues, "application/fhir+json") {
|
|
ctx, _ = log.WriteWarnWithFields(
|
|
ctx,
|
|
fmt.Sprintf("%s: application/fhir+json is the only supported response format", responseutils.FormatErr),
|
|
logrus.Fields{"resp_status": http.StatusBadRequest},
|
|
)
|
|
rw.OpOutcome(ctx, w, http.StatusBadRequest, responseutils.FormatErr, "application/fhir+json is the only supported response format")
|
|
return
|
|
}
|
|
|
|
preferValues := parseHeaderValues(h, "Prefer")
|
|
if len(preferValues) == 0 {
|
|
ctx, _ = log.WriteWarnWithFields(
|
|
ctx,
|
|
fmt.Sprintf("%s: Prefer header is required", responseutils.FormatErr),
|
|
logrus.Fields{"resp_status": http.StatusBadRequest},
|
|
)
|
|
rw.OpOutcome(ctx, w, http.StatusBadRequest, responseutils.FormatErr, "Prefer header is required")
|
|
return
|
|
}
|
|
|
|
if !hasHeaderToken(preferValues, "respond-async") {
|
|
ctx, _ = log.WriteWarnWithFields(
|
|
ctx,
|
|
fmt.Sprintf("%s: Only asynchronous responses are supported", responseutils.FormatErr),
|
|
logrus.Fields{"resp_status": http.StatusBadRequest},
|
|
)
|
|
rw.OpOutcome(ctx, w, http.StatusBadRequest, responseutils.FormatErr, "Only asynchronous responses are supported")
|
|
return
|
|
}
|
|
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// hasHeaderToken checks if targetToken exists within the parsed header values,
|
|
// stripping optional parameters (anything after ';') and performing case-insensitive matching.
|
|
func hasHeaderToken(values []string, targetToken string) bool {
|
|
for _, v := range values {
|
|
token, _, _ := strings.Cut(v, ";")
|
|
if strings.EqualFold(strings.TrimSpace(token), targetToken) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// parseHeaderValues extracts all values from a header, handling both multi-line
|
|
// header fields and comma-separated parameter lists within a single header value.
|
|
// It returns a slice of trimmed tokens.
|
|
func parseHeaderValues(h http.Header, headerName string) []string {
|
|
values := h.Values(headerName)
|
|
if len(values) == 0 {
|
|
return nil
|
|
}
|
|
results := make([]string, 0, len(values))
|
|
for _, rawValue := range values {
|
|
for _, part := range strings.Split(rawValue, ",") {
|
|
trimmed := strings.TrimSpace(part)
|
|
if trimmed != "" {
|
|
results = append(results, trimmed)
|
|
}
|
|
}
|
|
}
|
|
return results
|
|
}
|
|
|
|
func getKeys(kv map[string]struct{}) []string {
|
|
keys := make([]string, 0, len(kv))
|
|
for k := range kv {
|
|
keys = append(keys, k)
|
|
}
|
|
return keys
|
|
}
|
|
|
|
var versionExp = regexp.MustCompile(`\/api\/(v\d+)\/`)
|
|
|
|
func getVersion(path string) (string, error) {
|
|
parts := versionExp.FindStringSubmatch(path)
|
|
if len(parts) != 2 {
|
|
return "", fmt.Errorf("cannot retrieve version: not enough parts in path")
|
|
}
|
|
return parts[1], nil
|
|
}
|
|
|
|
type fhirResponseWriter interface {
|
|
Exception(context.Context, http.ResponseWriter, int, string, string)
|
|
NotFound(context.Context, http.ResponseWriter, int, string, string)
|
|
OpOutcome(context.Context, http.ResponseWriter, int, string, string)
|
|
}
|
|
|
|
func getRespWriter(version string) (fhirResponseWriter, error) {
|
|
switch version {
|
|
case "v1":
|
|
return responseutils.NewFhirResponseWriter(), nil
|
|
case "v2":
|
|
return responseutilsv2.NewFhirResponseWriter(), nil
|
|
case constants.V3Version:
|
|
return responseutilsv3.NewFhirResponseWriter(), nil
|
|
default:
|
|
return nil, fmt.Errorf("unexpected API version: %s", version)
|
|
}
|
|
}
|
|
|
|
func getResponseWriterFromRequestPath(w http.ResponseWriter, r *http.Request) (fhirResponseWriter, string) {
|
|
version, err := getVersion(r.URL.Path)
|
|
if err != nil {
|
|
logger := log.GetCtxLogger(r.Context())
|
|
logger.WithField("resp_status", http.StatusBadRequest).Error(err)
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return nil, ""
|
|
}
|
|
rw, err := getRespWriter(version)
|
|
if err != nil {
|
|
logger := log.GetCtxLogger(r.Context())
|
|
logger.WithField("resp_status", http.StatusBadRequest).Error(err)
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return nil, ""
|
|
}
|
|
|
|
return rw, version
|
|
}
|