using Hl7.Fhir.Model; using Hl7.Fhir.Serialization; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; /// /// FHIR R4 inbound facade: single-resource create and transaction Bundle processing. /// [ApiController] [Route("fhir/R4")] [AuthorizePermission(ClinicalPermissions.FhirIngest)] [ServiceFilter(typeof(FhirExceptionFilter))] public class FhirIngestController : ControllerBase { private static readonly FhirJsonParser Parser = new(); private static readonly FhirJsonSerializer Serializer = new(); private readonly IPatientService _patients; private readonly IEncounterService _encounters; private readonly IObservationService _observations; private readonly IMedicationService _medications; private readonly IExternalIdentifierService _identifiers; private readonly PatientFhirMapper _patientMapper; private readonly EncounterFhirMapper _encounterMapper; private readonly ObservationFhirMapper _observationMapper; private readonly MedicationAdministrationFhirMapper _medMapper; private readonly FhirBundleProcessor _bundleProcessor; private readonly FhirOptions _options; private readonly ClinicalMetrics _metrics; public FhirIngestController( IPatientService patients, IEncounterService encounters, IObservationService observations, IMedicationService medications, IExternalIdentifierService identifiers, PatientFhirMapper patientMapper, EncounterFhirMapper encounterMapper, ObservationFhirMapper observationMapper, MedicationAdministrationFhirMapper medMapper, FhirBundleProcessor bundleProcessor, IOptions options, ClinicalMetrics metrics) { _patients = patients; _encounters = encounters; _observations = observations; _medications = medications; _identifiers = identifiers; _patientMapper = patientMapper; _encounterMapper = encounterMapper; _observationMapper = observationMapper; _medMapper = medMapper; _bundleProcessor = bundleProcessor; _options = options.Value; _metrics = metrics; } /// /// Creates or updates a Patient from a FHIR R4 Patient resource (idempotent by identifier). /// /// The persisted Patient resource with Location header. [HttpPost("Patient")] [Consumes("application/fhir+json")] [Produces("application/fhir+json")] [ProducesResponseType(typeof(Hl7.Fhir.Model.Patient), StatusCodes.Status201Created)] [ProducesResponseType(typeof(OperationOutcome), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(OperationOutcome), StatusCodes.Status422UnprocessableEntity)] [ProducesResponseType(typeof(OperationOutcome), StatusCodes.Status409Conflict)] [ProducesResponseType(typeof(OperationOutcome), StatusCodes.Status500InternalServerError)] public async Task CreatePatient() { var fhir = await ParseBodyAsync(); var req = _patientMapper.ToUpsertRequest(fhir); var patient = await _patients.RegisterOrUpdateByIdentifierAsync(req); var hospitalId = await _identifiers.FindPrimaryIdentifierAsync( ExternalResourceType.Patient, patient.Id, _options.PatientIdentifierSystems); var response = _patientMapper.ToFhirResponse(patient, hospitalId); _metrics.FhirIngestTotal.WithLabels("Patient", "success").Inc(); Response.Headers.Location = $"{Request.Path}/{patient.Id}"; return Serialize(response, StatusCodes.Status201Created); } /// /// Creates or updates an Encounter from a FHIR R4 Encounter resource (idempotent by identifier). /// /// The persisted Encounter resource with Location header. [HttpPost("Encounter")] [Consumes("application/fhir+json")] [Produces("application/fhir+json")] [ProducesResponseType(typeof(Hl7.Fhir.Model.Encounter), StatusCodes.Status201Created)] [ProducesResponseType(typeof(OperationOutcome), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(OperationOutcome), StatusCodes.Status422UnprocessableEntity)] [ProducesResponseType(typeof(OperationOutcome), StatusCodes.Status409Conflict)] [ProducesResponseType(typeof(OperationOutcome), StatusCodes.Status500InternalServerError)] public async Task CreateEncounter() { var fhir = await ParseBodyAsync(); var req = await _encounterMapper.ToUpsertRequestAsync(fhir); var encounter = await _encounters.OpenOrUpdateByIdentifierAsync(req); var hospitalId = await _identifiers.FindPrimaryIdentifierAsync( ExternalResourceType.Encounter, encounter.Id, _options.EncounterIdentifierSystems); var response = _encounterMapper.ToFhirResponse(encounter, hospitalId); _metrics.FhirIngestTotal.WithLabels("Encounter", "success").Inc(); Response.Headers.Location = $"{Request.Path}/{encounter.Id}"; return Serialize(response, StatusCodes.Status201Created); } /// /// Ingests one or more observations from a FHIR R4 Observation resource. /// /// The last persisted Observation resource with Location header. [HttpPost("Observation")] [Consumes("application/fhir+json")] [Produces("application/fhir+json")] [ProducesResponseType(typeof(Hl7.Fhir.Model.Observation), StatusCodes.Status201Created)] [ProducesResponseType(typeof(OperationOutcome), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(OperationOutcome), StatusCodes.Status422UnprocessableEntity)] [ProducesResponseType(typeof(OperationOutcome), StatusCodes.Status409Conflict)] [ProducesResponseType(typeof(OperationOutcome), StatusCodes.Status500InternalServerError)] public async Task CreateObservation() { var fhir = await ParseBodyAsync(); var mapped = await _observationMapper.ToIngestRequestsAsync(fhir); Resource? lastResponse = null; foreach (var item in mapped) { var result = await _observations.IngestAsync(item.EncounterId, item.Request); lastResponse = new Hl7.Fhir.Model.Observation { Id = result.Observation.Id.ToString(), Status = ObservationStatus.Final, Code = new CodeableConcept("http://loinc.org", item.Request.ObservationCode), Value = new Quantity(item.Request.Value, item.Request.Unit) }; } _metrics.FhirIngestTotal.WithLabels("Observation", "success").Inc(); Response.Headers.Location = Request.Path.Value!; return Serialize(lastResponse!, StatusCodes.Status201Created); } /// /// Records a medication administration from a FHIR R4 MedicationAdministration resource. /// /// The persisted MedicationAdministration resource with Location header. [HttpPost("MedicationAdministration")] [Consumes("application/fhir+json")] [Produces("application/fhir+json")] [ProducesResponseType(typeof(Hl7.Fhir.Model.MedicationAdministration), StatusCodes.Status201Created)] [ProducesResponseType(typeof(OperationOutcome), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(OperationOutcome), StatusCodes.Status422UnprocessableEntity)] [ProducesResponseType(typeof(OperationOutcome), StatusCodes.Status409Conflict)] [ProducesResponseType(typeof(OperationOutcome), StatusCodes.Status500InternalServerError)] public async Task CreateMedicationAdministration() { var fhir = await ParseBodyAsync(); var (req, encounterId) = await _medMapper.ToCreateRequestAsync(fhir); var med = await _medications.CreateAsync(encounterId, req); var response = new Hl7.Fhir.Model.MedicationAdministration { Id = med.Id.ToString() }; _metrics.FhirIngestTotal.WithLabels("MedicationAdministration", "success").Inc(); Response.Headers.Location = $"{Request.Path}/{med.Id}"; return Serialize(response, StatusCodes.Status201Created); } /// /// Processes a FHIR R4 transaction Bundle (e.g. ADT admit with Patient + Encounter). /// /// A transaction-response Bundle with per-entry outcomes. [HttpPost] [Consumes("application/fhir+json")] [Produces("application/fhir+json")] [ProducesResponseType(typeof(Bundle), StatusCodes.Status200OK)] [ProducesResponseType(typeof(OperationOutcome), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(OperationOutcome), StatusCodes.Status422UnprocessableEntity)] [ProducesResponseType(typeof(OperationOutcome), StatusCodes.Status409Conflict)] [ProducesResponseType(typeof(OperationOutcome), StatusCodes.Status500InternalServerError)] public async Task ProcessBundle() { using var reader = new StreamReader(Request.Body); var json = await reader.ReadToEndAsync(); var bundle = Parser.Parse(json); if (bundle.Type != Bundle.BundleType.Transaction) throw new FhirMappingException("Only transaction Bundles are supported.", "not-supported"); var responseBundle = await _bundleProcessor.ProcessTransactionAsync(bundle); _metrics.FhirIngestTotal.WithLabels("Bundle", "success").Inc(); return Serialize(responseBundle); } private async Task ParseBodyAsync() where T : Resource { using var reader = new StreamReader(Request.Body); var json = await reader.ReadToEndAsync(); return Parser.Parse(json); } private ContentResult Serialize(Resource resource, int statusCode = StatusCodes.Status200OK) => new() { Content = Serializer.SerializeToString(resource), ContentType = "application/fhir+json", StatusCode = statusCode }; }