Files
stack/dev/vendor/bcda-app/bcda/web/middleware/validation.go
kert 7e53d9360a chore(bcda): vendor CMSgov/bcda-app + bcda.cms.gov v3 artefacts sparsely, flatten the v3 data dictionary into crosswalk seeds, weekly drift workflow
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
2026-10-25 13:08:05 -04:00

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
}