Una pequeña aproximación a HL7/FHIR con Ninja y herramientas modernas.
Interoperabilidad en salud · FHIR R5
FHIR en Django, sin romper nada
Un mapper, tres endpoints y una API que responde con recursos FHIR
válidos. Incluye los siete bugs que encontré al ejecutar el código, y
el que costó una tarde: from __future__ import annotations
rompe @paginate.
Dos sistemas que no se hablan
Tienes un Django con años de historia clínica. Al lado, un hospital con su propio sistema, y otro software que quiere consumir tus datos. Nadie habla el mismo idioma.
La respuesta ingenua es exportar un JSON a mano:
{"nombre": "Juan Pérez", "cedula": "1234567890", ...}
Funciona hasta que el segundo consumidor pregunta por el teléfono, el
tercero quiere el código postal normalizado, y el cuarto espera
gender como male/female/other/unknown
y le mandas "M".
FHIR es el acuerdo sobre el idioma. Define
Patient, Observation, Encounter
como estructuras con nombre, tipo y cardinalidad conocidos. Cada sistema
que habla FHIR entiende los mismos campos sin negociar entre sí.
El punto clave para un ingeniero: FHIR no es un formato de serialización, es un modelo de datos con forma. La diferencia se ve en el mapper: no estás convirtiendo una fila a JSON, estás declarando qué campo de tu tabla corresponde a qué elemento del estándar.
La interoperabilidad no es un formato. Es un cable: dos sistemas que antes no se hablaban, con un conector en el medio.
Lo que vas a construir
Un único archivo api.py que hace tres cosas:
- Convierte un
Pacientede Django en unPatientde FHIR. - Lo expone en JSON (y XML) por HTTP.
- Documenta el contrato en OpenAPI automáticamente.
El resto del artículo son los detalles, incluidos los que duelen.
FHIR en 30 segundos
No necesitas leer la especificación. Necesitas esto:
| Recurso | Qué representa | Tu tabla |
|---|---|---|
Patient | Persona que recibe atención | Paciente + Persona |
Practitioner | Profesional que presta atención | Medico |
Organization | Institución | IPS |
Encounter | Un encuentro de atención | Cita |
Observation | Un hallazgo medido (presión, peso) | SignosVitales |
Un Patient de FHIR trae, entre otras cosas:
identifier (una lista de identificadores con su
system), name (lista de HumanName),
gender, birthDate, address,
telecom y contact.
Dos detalles que se salen de la especificación y te ahorran un dolor de cabeza:
-
Los elementos ausentes se omiten, no se emiten como
null. Es un requisito del estándar, no una preferencia de estilo. Lo mido en la sección 11. -
La cardinalidad importa.
HumanName.familyes0..1 string, no una lista. Era0..*en DSTU2 y cambió en STU3; así sigue en R4 y R5 (historial de cambios de HL7). Copia un ejemplo viejo y te revienta conValidationError.
Sobre la versión: fhir.resources 8.3.0 implementa FHIR R5,
confirmado en el propio paquete. Trae además las variantes
R4B y STU3 si tu interlocutor no ha migrado.
Instalación
pip install django-ninja fhir.resources lxml
Versiones con las que probé todo lo de este artículo:
Django 6.1.2
django-ninja 1.7.1
fhir.resources 8.3.0
fhir-core 1.1.12
pydantic 2.14.1
lxml 6.1.3
fhir.resources 8.x requiere Pydantic v2. Si tu proyecto está
en v1, django-ninja te va a pedir la v2 de todas formas.
Planifica esa migración antes, no en medio de la integración.
lxml solo hace falta para el endpoint XML: sin él,
model_dump_xml() falla con ModuleNotFoundError: You need
to install ``lxml`` package to use this method.
Los modelos
Los míos, simplificados:
import uuid
from django.db import models
class Persona(models.Model):
nombres = models.CharField(max_length=120)
primer_apellido = models.CharField(max_length=80, blank=True)
segundo_apellido = models.CharField(max_length=80, blank=True)
cedula = models.CharField(max_length=32, null=True, blank=True, unique=True)
genero = models.CharField(max_length=8, blank=True)
nacimiento = models.DateField(null=True, blank=True)
direccion = models.CharField(max_length=255, blank=True)
ciudad = models.CharField(max_length=80, blank=True)
codigo_postal = models.CharField(max_length=16, blank=True)
pais = models.CharField(max_length=80, blank=True, default="Colombia")
telefono = models.CharField(max_length=32, blank=True)
correo = models.EmailField(blank=True)
class Paciente(models.Model):
id_paciente = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
persona = models.ForeignKey(Persona, on_delete=models.CASCADE,
related_name="pacientes", null=True, blank=True)
contacto_emergencia = models.CharField(max_length=32, blank=True)
activo = models.BooleanField(default=True)
fecha_registro = models.DateTimeField(auto_now_add=True)
Tres decisiones que importan:
-
id_pacientees UUID. Se convierte en elPatient.idde FHIR, y un identificador opaco no filtra datos de la numeración de tu base. -
personaes nullable. En una base real hay pacientes sin ficha completa. El mapper los omite del listado y el detalle responde 404, sin romper la API. -
cedulallevanull=True. Conunique=Trueyblank=True, dos personas sin cédula guardan""y la segunda revienta conIntegrityError: UNIQUE constraint failed. En salud rural hay muchos pacientes sin documento: es el caso normal, no el raro. La documentación de Django pidenull=Truejusto para esta combinación.
El mapper: tu modelo contra el estándar
Aquí está el corazón. fhir.resources te da clases tipadas
(Patient, HumanName, Identifier,
Address, ContactPoint, PatientContact)
y tú declaras la correspondencia.
import logging
from typing import Optional
from fhir.resources.address import Address
from fhir.resources.contactpoint import ContactPoint
from fhir.resources.humanname import HumanName
from fhir.resources.identifier import Identifier
from fhir.resources.patient import Patient, PatientContact
from .models import Paciente, Persona
logger = logging.getLogger(__name__)
SYSTEM_LOCAL_ID = "urn:example:patient-id"
SYSTEM_CEDULA = "urn:example:cedula"
GENDER_MAP = {
"M": "male", "F": "female", "O": "other", "X": "unknown",
"N": "other", "P": "unknown", "T": "other", "I": "other", "D": "unknown",
}
# El mismo mapa, al revés, para la búsqueda: "other" -> ["O", "N", "T", "I"]
FHIR_GENDERS = frozenset(GENDER_MAP.values())
CODIGOS_POR_GENERO = {}
for _local, _fhir in GENDER_MAP.items():
CODIGOS_POR_GENERO.setdefault(_fhir, []).append(_local)
def _human_name(persona: Persona) -> HumanName:
given = [persona.nombres] if persona.nombres else []
nombre_completo = " ".join(
p for p in (persona.nombres, persona.primer_apellido, persona.segundo_apellido) if p
)
return HumanName(
use="official",
given=given,
family=persona.primer_apellido or None, # <- string, no lista
text=nombre_completo or None,
)
def _address(persona: Persona) -> Optional[Address]:
if not any((persona.direccion, persona.ciudad, persona.codigo_postal, persona.pais)):
return None # <- sin datos, no hay address
address = Address()
if persona.direccion:
address.line = [persona.direccion]
if persona.ciudad:
address.city = persona.ciudad
if persona.codigo_postal:
address.postalCode = persona.codigo_postal
if persona.pais:
address.country = persona.pais
return address
def create_fhir_patient(paciente: Paciente) -> Optional[Patient]:
persona = paciente.persona
if persona is None:
logger.warning("Paciente %s sin Persona asociada; se omite", paciente.pk)
return None
patient = Patient(
id=str(paciente.pk),
identifier=[
Identifier(system=SYSTEM_LOCAL_ID, value=str(paciente.id_paciente)),
*([Identifier(system=SYSTEM_CEDULA, value=persona.cedula)] if persona.cedula else []),
],
name=[_human_name(persona)],
gender=GENDER_MAP.get((persona.genero or "").upper(), "unknown"),
active=paciente.activo,
birthDate=persona.nacimiento,
telecom=[
t for t in (
ContactPoint(system="phone", value=persona.telefono) if persona.telefono else None,
ContactPoint(system="email", value=persona.correo) if persona.correo else None,
) if t is not None
],
)
direccion = _address(persona)
if direccion is not None:
patient.address = [direccion]
if paciente.contacto_emergencia:
patient.contact = [
PatientContact(
name=HumanName(text="Contacto de emergencia"),
telecom=[ContactPoint(system="phone", value=paciente.contacto_emergencia)],
)
]
return patient
def _patients_to_fhir(patients):
# Convierte una sola vez por paciente y descarta los incompletos (ver bug 04).
for paciente in patients:
resource = create_fhir_patient(paciente)
if resource is not None:
yield resource
Tres cosas que escribí a propósito y que casi nadie escribe:
1. family es un string, no una lista
El borrador original usaba family=[persona.primer_apellido].
Con fhir.resources 8.3.0 eso es:
pydantic.errors.ValidationError: 1 validation error for HumanName
family
Input should be a valid string [type=string_type, input_value=['Pérez'], input_type=list]
2. No invento datos
El borrador rellenaba address.city con
"Villavicencio" y postalCode con
"500005" cuando el registro no los tenía. Un consumidor que
confía en tu API va a propagar esa ciudad inventada a un hospital. Si el
dato no existe, el elemento no se emite.
3. El system de la cédula es propio
El borrador usaba http://hl7.org/fhir/sid/us-ssn para la
cédula colombiana. Ese URI declara un identificador de Estados Unidos: un
consumidor internacional lo leería como un SSN. Cada identificador
necesita un system que declare su autoridad.
Los endpoints
import uuid
from typing import Optional
from django.db.models import Q
from django.http import Http404, HttpResponse
from django.shortcuts import get_object_or_404
from ninja import NinjaAPI, Query, Schema
from ninja.pagination import PageNumberPagination, paginate
api = NinjaAPI(
title="API FHIR de Pacientes",
description="Endpoint FHIR compatible para gestión de pacientes en salud rural",
version="1.0.0",
)
# FHIR exige omitir los elementos ausentes, no emitirlos como null.
EXCLUDE_NONE = True
class PatientFilter(Schema):
gender: Optional[str] = None
name: Optional[str] = None
city: Optional[str] = None
identifier: Optional[str] = None
@api.get("/patients", response=list[Patient], summary="Listar pacientes en formato FHIR",
tags=["Patient"], exclude_none=EXCLUDE_NONE)
@paginate(PageNumberPagination, per_page=25)
def list_patients(request, filters: PatientFilter = Query(...)):
patients = Paciente.objects.select_related("persona").all()
if filters.gender:
valor = filters.gender.lower()
if valor not in FHIR_GENDERS: # male | female | other | unknown
return []
condicion = Q(persona__genero__in=CODIGOS_POR_GENERO[valor])
if valor == "unknown":
condicion |= Q(persona__genero="") # el mapper también lo declara unknown
patients = patients.filter(condicion)
if filters.name:
patients = patients.filter(
Q(persona__nombres__icontains=filters.name)
| Q(persona__primer_apellido__icontains=filters.name)
| Q(persona__segundo_apellido__icontains=filters.name)
)
if filters.city:
patients = patients.filter(persona__ciudad__icontains=filters.city)
if filters.identifier:
patients = patients.filter(persona__cedula=filters.identifier)
return list(_patients_to_fhir(patients))
@api.get("/patients/{patient_id}", response={200: Patient, 404: dict},
summary="Obtener un paciente por su identificador FHIR",
tags=["Patient"], exclude_none=EXCLUDE_NONE)
def get_patient(request, patient_id: str):
try:
uuid.UUID(patient_id)
except (ValueError, AttributeError, TypeError):
raise Http404("Identificador de paciente inválido")
paciente = get_object_or_404(
Paciente.objects.select_related("persona"), id_paciente=patient_id
)
resource = create_fhir_patient(paciente)
if resource is None:
raise Http404("El paciente no tiene una Persona asociada")
return resource
Y el montaje:
from django.urls import path
from .api import api
urlpatterns = [path("api/", api.urls)]
Más un endpoint de capacidad, que es lo primero que un cliente mira antes de integrarse:
@api.get("/server/metadata", response=dict, summary="Declaración de capacidades del servidor",
tags=["Server"], exclude_none=EXCLUDE_NONE)
def server_metadata(request):
return {
"resourceType": "CapabilityStatement",
"status": "active",
"fhirVersion": "5.0.0",
"format": ["json", "xml"],
"rest": [{
"mode": "server",
"resource": [{
"type": "Patient",
"interaction": [{"code": "read"}, {"code": "search-type"}],
"searchParam": [
{"name": "gender", "type": "token"},
{"name": "name", "type": "string"},
{"name": "city", "type": "string"},
{"name": "identifier", "type": "token"},
],
}],
}],
}
Y la variante XML, que fhir.resources serializa con
model_dump_xml():
@api.get("/patients/{patient_id}/xml", response={200: str, 404: dict},
tags=["Patient"], include_in_schema=False)
def get_patient_xml(request, patient_id: str):
try:
uuid.UUID(patient_id)
except (ValueError, AttributeError, TypeError):
raise Http404("Identificador de paciente inválido")
paciente = get_object_or_404(
Paciente.objects.select_related("persona"), id_paciente=patient_id
)
resource = create_fhir_patient(paciente)
if resource is None:
raise Http404("El paciente no tiene una Persona asociada")
return HttpResponse(resource.model_dump_xml(), content_type="application/fhir+xml")
Lo que la API expone realmente
Verificado contra el esquema OpenAPI publicado:
| Método | Ruta | Resumen |
|---|---|---|
| GET | /api/patients | Listar pacientes en formato FHIR |
| GET | /api/patients/{patient_id} | Obtener un paciente por su identificador FHIR |
| GET | /api/server/metadata | Declaración de capacidades del servidor |
Y en http://localhost:8000/api/docs tienes Swagger, generado
del response_model. No hay trabajo extra: al declarar
response=list[Patient], Ninja deriva el esquema del propio
Patient de fhir.resources.
El mapa de género, y por qué importa
Tu base de datos tiene los códigos que tiene. FHIR R5 admite exactamente
cuatro valores para gender: male,
female, other, unknown.
Lo que pasa al colapsar nueve códigos locales sobre cuatro valores:
| Valor FHIR | Códigos locales | Cantidad |
|---|---|---|
other | I, N, O, T | 4 |
unknown | D, P, X | 3 |
male | M | 1 |
female | F | 1 |
Cuatro códigos distintos convergen en other, tres en
unknown. Ese es el punto: FHIR no puede representar
toda la granularidad de tu dominio, y el mapa tiene que ser una
decisión explícita, no un if/elif que alguien escribió hace años y nadie revisó. El mismo mapa tiene que
funcionar al revés para la búsqueda: un cliente FHIR pide
?gender=other y espera los cuatro códigos (ver bug 06).
Si tu sistema registra algo que FHIR no modela (identidad de género,
etnia, preferencias de idioma), el camino correcto es
extensión, no un código improvisado:
Patient.extension con tu propia URL. No abuses de
other.
Los siete bugs del borrador original
Todos reproducidos contra las versiones instaladas. Varios no lanzan ningún error: responden 200 con datos incorrectos o vacíos, no dejan rastro en los logs, y se descubren cuando otro sistema te devuelve información que no cuadra.
family como lista
name = HumanName(use="official", given=[persona.nombres],
family=[persona.primer_apellido])
pydantic.errors.ValidationError: 1 validation error for HumanName
family
Input should be a valid string [type=string_type, input_value=['Pérez'], input_type=list]
Fix: family=persona.primer_apellido or None — en R5 es 0..1 string.
Imports y métodos que no existen
import structlog # no está en el pip install
fhir_patient.as_xml() # no existe en fhir.resources 8.x
# "Usar fhir.resources.search.SearchParameter para validación"
ModuleNotFoundError: No module named 'structlog'
hasattr(Patient, "as_xml") -> False
ModuleNotFoundError: No module named 'fhir.resources.search'
Tres referencias a cosas que no existen en un entorno limpio. La API ni
siquiera arranca: el import structlog falla antes de que
Django registre la primera ruta.
Fix: logging estándar (o structlog en requirements), model_dump_xml() + lxml, y fuera el módulo inexistente.
La cédula declarada como sid/us-ssn
Identifier(system="http://hl7.org/fhir/sid/us-ssn", value=persona.cedula)
No rompe nada. Semánticamente es peor que romper: un consumidor internacional lee un número de cédula colombiana como un SSN de Estados Unidos.
Fix: un system propio (urn:example:cedula) que declare la autoridad.
create_fhir_patient llamado dos veces por paciente
return [create_fhir_patient(p) for p in patients if create_fhir_patient(p)]
La condición evalúa la función y luego la lista vuelve a llamarla. El mapper corre dos veces por paciente. Con N=1000 son 2000 conversiones y el doble de trabajo de CPU.
Fix: un generador que convierte una vez y filtra los incompletos.
None como respuesta de un 404
@api.get("/patients/{patient_id}", response=Patient)
def get_patient(request, patient_id: str):
try:
paciente = Paciente.objects.get(id_paciente=patient_id)
return create_fhir_patient(paciente)
except Paciente.DoesNotExist:
return None
Ninja valida lo que devuelves contra response=Patient.
None no es un Patient: la validación falla y la
API responde 500. El cliente ve una caída del servidor
donde debía ver "ese paciente no existe". Con un id mal formado pasa lo
mismo: .get(id_paciente="abc") lanza
ValidationError de Django y también termina en 500.
Fix: validar el UUID, get_object_or_404 y raise Http404 → 404 real.
El filtro de género habla otro idioma
if filters.gender:
patients = patients.filter(persona__genero=filters.gender)
Un cliente FHIR busca con el valor del estándar:
?gender=male. Tu base guarda "M". El filtro no
encuentra nada y la API responde 200 con una lista vacía: sin error, sin
log. Mi primera corrección, .upper(), repetía el problema:
"MALE" tampoco existe en la base. Y
?gender=other tiene que traer los cuatro códigos que
colapsan en other.
De paso: gender: str = None funciona en Pydantic v2 (los
valores por defecto no se validan), pero tu verificador de tipos lo
marca. Optional[str] = None declara la intención.
Fix: traducir con el mapa inverso (CODIGOS_POR_GENERO) y no filtrar por valores fuera del estándar.
models.Q sin importar models
patients.filter(
models.Q(persona__nombres__icontains=filters.name) | ...
)
models no está importado en el archivo:
NameError en la primera petición con ?name=.
Los filtros que nunca se probaron nunca fallaron.
Fix: from django.db.models import Q.
El bug que costó una tarde
@paginate + from __future__ import annotations.
Este no estaba en el borrador original; me lo encontré al escribir las
pruebas.
El síntoma:
pydantic.errors.PydanticUserError: `QueryParams` is not fully defined;
you should define `PatientFilter`, then call `QueryParams.model_rebuild()`.
No era un problema de documentación: con ese import se caían el listado y el esquema OpenAPI. De las 22 pruebas que tenía en ese momento, 17 pasaron a rojo.
El diagnóstico
Lo aislé con bisección sobre copias del archivo, cambiando una variable a la vez:
| Variante | Resultado |
|---|---|
future annotations + @paginate + Schema | FALLA |
future annotations + @paginate + esquema importado | FALLA |
future annotations + sin @paginate + Schema | OK |
sin future annotations + @paginate + Schema | OK |
future annotations + @paginate + parámetros escalares | OK |
Dos factores, y los dos hacen falta. El mecanismo está en
ninja/signature/utils.py:
def get_typed_signature(call: Callable[..., Any]) -> inspect.Signature:
"""Finds call signature and resolves all forwardrefs"""
signature = inspect.signature(call, **_FORWARDREF_KWARGS)
globalns = getattr(call, "__globals__", {})
...
annotation=get_typed_annotation(param, globalns),
Con from __future__ import annotations, la anotación de un
parámetro no es el objeto clase sino el string:
original.__annotations__['filters'] = 'PatientFilter' # type: str
Y el globalns sale de call.__globals__. Aquí
call es el wrapper que @paginate pone por encima
de tu función: view_with_pagination, definido dentro
de ninja/pagination.py. Su __globals__
es el módulo interno de Ninja, donde PatientFilter no existe:
wrapper.__globals__['__name__'] = ninja.pagination
'PatientFilter' in wrapper.__globals__ = False
'PatientFilter' in original.__globals__ = True
Resultado: ForwardRef('PatientFilter') irresoluble, y Pydantic
no puede reconstruir el modelo QueryParams.
Optional sí está importado en
ninja/pagination.py. El string "Optional[str]"
sí resuelve contra ese namespace. Es casualidad, no diseño — y por eso
el error te confunde: el mismo archivo falla con un tipo de filtro y
funciona con otro.
El fix
Quitar from __future__ import annotations del módulo donde
defines los endpoints, y anotar con Optional[str] /
list[Patient] explícitamente. En Python 3.10+
X | None también funciona.
La alternativa es no usar @paginate con esquemas Pydantic:
declara los parámetros de filtro uno por uno como escalares.
Probarlo
Una API de interoperabilidad sin pruebas es una API que rompiste el día
que actualizaste fhir.resources.
import datetime
import pytest
from pacientes.models import Paciente, Persona
pytestmark = pytest.mark.django_db
@pytest.fixture
def persona():
return Persona.objects.create(
nombres="Juan", primer_apellido="Pérez", segundo_apellido="Gómez",
cedula="1234567890", genero="M",
nacimiento=datetime.date(1990, 5, 15),
direccion="Calle 123 #45-67", ciudad="Villavicencio",
codigo_postal="500005", pais="Colombia",
telefono="3101234567", correo="juan.perez@example.com",
)
@pytest.fixture
def paciente(persona):
return Paciente.objects.create(persona=persona, contacto_emergencia="3209876543")
def test_name_family_es_string_no_lista(client, paciente):
"""Regresión: en R5 `family` es 0..1 string. Una lista rompe el parseo."""
items = client.get("/api/patients").json()["items"]
nombre = items[0]["name"][0]
assert isinstance(nombre["family"], str)
assert nombre["family"] == "Pérez"
assert nombre["given"] == ["Juan"]
def test_404_real_para_id_inexistente(client):
"""Regresión: el borrador devolvía 200 con `null`."""
respuesta = client.get("/api/patients/00000000-0000-0000-0000-000000000000")
assert respuesta.status_code == 404
def test_no_inventa_datos_faltantes(client):
"""Sin ciudad no se rellena 'Villavicencio': el recurso omite el campo."""
Persona.objects.create(nombres="Ana", primer_apellido="Ruiz", genero="F")
Paciente.objects.create(persona=Persona.objects.get(nombres="Ana"))
items = client.get("/api/patients").json()["items"]
assert "city" not in items[0]["address"][0]
Cada prueba de regresión explica en su docstring qué se rompió y por qué. Cuando el error vuelva en seis meses, no tienes que investigar de nuevo.
27 passed in 7.60s
Los códigos que verifiqué
| Petición | Código | Content-Type |
|---|---|---|
GET /api/patients | 200 | application/json |
GET /api/patients?gender=male | 200 | application/json |
GET /api/patients/<existente> | 200 | application/json |
GET /api/patients/<uuid-inexistente> | 404 | application/json |
GET /api/patients/<no-uuid> | 404 | application/json |
GET /api/patients/<id>/xml | 200 | application/fhir+xml |
GET /api/server/metadata | 200 | application/json |
GET /api/openapi.json | 200 | application/json |
GET /api/docs | 200 | text/html |
Nota el <no-uuid>: 404, no 500. El
borrador devolvía 500, porque get_object_or_404 con un
UUID inválido lanza ValidationError de Django dentro del
filtro. En una API de salud, un id basura del cliente es un error del
cliente, no una caída del servidor.
El detalle que más payload ahorra
Mido el mismo paciente serializado de las dos formas:
| Configuración | Claves | Nulos | Bytes |
|---|---|---|---|
exclude_none=False | 104 | 73 | 2.011 |
exclude_none=True | 31 | 0 | 752 |
73 de 104 claves eran null. Un
Patient con nombre, teléfono, dirección y contacto de emergencia
iba por el cable con 73 campos vacíos: "meta": null,
"implicitRules": null, "deceasedBoolean": null,
"maritalStatus": null, "photo": null...
No es solo estético. Un consumidor que valida con el esquema estricto de
FHIR puede rechazar null donde el estándar dice "omite el
elemento". En una página de 25 pacientes con esa misma forma serían
25 × 73 = 1.825 claves nulas por respuesta.
El parámetro va en el decorador de cada operación, no en NinjaAPI(...):
@api.get("/patients", response=list[Patient], exclude_none=True)
@paginate(PageNumberPagination, per_page=25)
def list_patients(request, filters: PatientFilter = Query(...)):
...
NinjaAPI(exclude_none=True) lanza
TypeError. No existe ese keyword en el constructor.
Antes de que esto toque producción
Autenticación y autorización. La API del ejemplo está abierta. Antes de exponerla:
import secrets
from django.conf import settings
from ninja.security import HttpBearer
class AuthBearer(HttpBearer):
def authenticate(self, request, token):
# Devolver None hace que Ninja responda 401.
if secrets.compare_digest(token, settings.FHIR_API_TOKEN):
return token
return None
api = NinjaAPI(auth=AuthBearer())
Lo probé: sin token o con un token incorrecto responde
401; con el correcto, 200. El token vive en
settings (y de ahí, en una variable de entorno), nunca en
el código.
En salud eso no basta: necesitas auditoría de acceso (quién leyó qué historial, cuándo), porque el acceso a datos clínicos suele estar regulado, y permisos por recurso, no un token de todo o nada.
fhir.resources valida la forma del recurso. No valida
quién puede verlo, ni lo cifra, ni lo audita. Esos tres son tu
responsabilidad.
Cuidado con los logs. Un
logger.info(f"...{resource}") en nivel INFO
escribe datos clínicos en tu log de aplicación. En el ejemplo uso
logger.warning solo para el caso de un paciente sin
Persona, sin volcar el recurso.
HTTPS y CORS si el consumidor es un navegador en otro dominio.
Lo que sigue
El mismo mapper sirve para el resto de recursos:
Observationpara signos vitales y resultados de laboratorio.Encounterpara cada cita.ServiceRequestpara la lista de espera.Organizationpara la EAPB o el hospital.
El patrón no cambia: un modelo Django, un mapper, un endpoint. Lo que
cambia es el nombre de la clase de fhir.resources y los
campos que el estándar exige para ese recurso.
Si vas a extenderlo, empieza por el CapabilityStatement: es
lo que le dice a un cliente qué puedes hacer, y obliga a que cada
endpoint nuevo declare su searchParam.
Lo difícil no es integrar FHIR. Es que el código que parece correcto devuelve 200 con datos falsos, y nadie lo nota hasta que otro sistema te devuelve información que no cuadra.
Lo que no deberías tener que descubrir
HumanName.familyes string, no lista.exclude_none=Trueno es un kwarg deNinjaAPI.from __future__ import annotationsrompe@paginatecon esquemas Pydantic.- Devolver
Noneen vez de lanzarHttp404produce un 500, no un 404. unique=True+blank=Truesinnull=Truerevienta con el segundo paciente sin cédula.
Y una regla que no está en ninguna especificación:
no inventes datos para llenar el hueco. Un
city por defecto parece una cortesía y es una mentira que se
propaga por toda la red de salud.
Ingeniero Eli Godoy
Cada afirmación técnica sale de una ejecución: la suite de pruebas, las respuestas reales de la API y las mediciones de serialización.
Comentarios
Publicar un comentario