209 lines
10 KiB
C#
209 lines
10 KiB
C#
using Hl7.Fhir.Model;
|
|
using Hl7.Fhir.Serialization;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.Extensions.Options;
|
|
|
|
/// <summary>
|
|
/// FHIR R4 inbound facade: single-resource create and transaction Bundle processing.
|
|
/// </summary>
|
|
[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<FhirOptions> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates or updates a Patient from a FHIR R4 Patient resource (idempotent by identifier).
|
|
/// </summary>
|
|
/// <returns>The persisted Patient resource with Location header.</returns>
|
|
[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<IActionResult> CreatePatient()
|
|
{
|
|
var fhir = await ParseBodyAsync<Hl7.Fhir.Model.Patient>();
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates or updates an Encounter from a FHIR R4 Encounter resource (idempotent by identifier).
|
|
/// </summary>
|
|
/// <returns>The persisted Encounter resource with Location header.</returns>
|
|
[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<IActionResult> CreateEncounter()
|
|
{
|
|
var fhir = await ParseBodyAsync<Hl7.Fhir.Model.Encounter>();
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Ingests one or more observations from a FHIR R4 Observation resource.
|
|
/// </summary>
|
|
/// <returns>The last persisted Observation resource with Location header.</returns>
|
|
[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<IActionResult> CreateObservation()
|
|
{
|
|
var fhir = await ParseBodyAsync<Hl7.Fhir.Model.Observation>();
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Records a medication administration from a FHIR R4 MedicationAdministration resource.
|
|
/// </summary>
|
|
/// <returns>The persisted MedicationAdministration resource with Location header.</returns>
|
|
[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<IActionResult> CreateMedicationAdministration()
|
|
{
|
|
var fhir = await ParseBodyAsync<Hl7.Fhir.Model.MedicationAdministration>();
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Processes a FHIR R4 transaction Bundle (e.g. ADT admit with Patient + Encounter).
|
|
/// </summary>
|
|
/// <returns>A transaction-response Bundle with per-entry outcomes.</returns>
|
|
[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<IActionResult> ProcessBundle()
|
|
{
|
|
using var reader = new StreamReader(Request.Body);
|
|
var json = await reader.ReadToEndAsync();
|
|
var bundle = Parser.Parse<Bundle>(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<T> ParseBodyAsync<T>() where T : Resource
|
|
{
|
|
using var reader = new StreamReader(Request.Body);
|
|
var json = await reader.ReadToEndAsync();
|
|
return Parser.Parse<T>(json);
|
|
}
|
|
|
|
private ContentResult Serialize(Resource resource, int statusCode = StatusCodes.Status200OK) =>
|
|
new()
|
|
{
|
|
Content = Serializer.SerializeToString(resource),
|
|
ContentType = "application/fhir+json",
|
|
StatusCode = statusCode
|
|
};
|
|
}
|