Una pequeña aproximación a HL7/FHIR con Ninja y herramientas modernas.

Interior de un puesto de salud rural: una tableta sobre una mesa de madera junto a un estetoscopio y un maletín de campo.

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.

Django 6.1.2
django-ninja 1.7.1
fhir.resources 8.3.0
pydantic 2.14.1
Pruebas 27 / 27 en verde
01

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:

json
{"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.

Esquema 1 · El cable Dos sistemas aislados y un cable de interoperabilidad Tu Django y otro sistema de salud no pueden intercambiar un JSON hecho a mano; se conectan cuando ambos hablan el recurso FHIR Patient. JSON A MANO ✕ RECURSO FHIR Tu Django modelos propios Otro sistema hospital, laboratorio un modelo de datos común entre dos sistemas distintos
Dos sistemas con su propio formato. El JSON a mano no cruza la barrera; el recurso FHIR sí.

Lo que vas a construir

Un único archivo api.py que hace tres cosas:

  1. Convierte un Paciente de Django en un Patient de FHIR.
  2. Lo expone en JSON (y XML) por HTTP.
  3. Documenta el contrato en OpenAPI automáticamente.

El resto del artículo son los detalles, incluidos los que duelen.

02

FHIR en 30 segundos

No necesitas leer la especificación. Necesitas esto:

Los cinco recursos que vas a necesitar primero
RecursoQué representaTu tabla
PatientPersona que recibe atenciónPaciente + Persona
PractitionerProfesional que presta atenciónMedico
OrganizationInstituciónIPS
EncounterUn encuentro de atenciónCita
ObservationUn 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.family es 0..1 string, no una lista. Era 0..* en DSTU2 y cambió en STU3; así sigue en R4 y R5 (historial de cambios de HL7). Copia un ejemplo viejo y te revienta con ValidationError.

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.

03

Instalación

bash
pip install django-ninja fhir.resources lxml

Versiones con las que probé todo lo de este artículo:

versiones medidas
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
Nota de dependencia

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.

04

Los modelos

Los míos, simplificados:

pacientes/models.py
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_paciente es UUID. Se convierte en el Patient.id de FHIR, y un identificador opaco no filtra datos de la numeración de tu base.
  • persona es 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.
  • cedula lleva null=True. Con unique=True y blank=True, dos personas sin cédula guardan "" y la segunda revienta con IntegrityError: UNIQUE constraint failed. En salud rural hay muchos pacientes sin documento: es el caso normal, no el raro. La documentación de Django pide null=True justo para esta combinación.
05

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.

pacientes/api.py
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
Esquema 2 · El mapper Mapeo de campos de Django a elementos FHIR Cada campo del modelo Persona se convierte en un elemento concreto del recurso FHIR Patient: nombres en name.given, primer apellido en name.family, cedula en identifier, genero en gender y nacimiento en birthDate. MODELO DJANGO RECURSO PATIENT persona.nombres name[0].given primer_apellido name[0].family persona.cedula identifier[1] persona.genero gender persona.nacimiento birthDate family = string (0..1) · given = lista (0..*)
Cada campo de tu tabla aterriza en un elemento concreto del estándar.

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:

ValidationError
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.

06

Los endpoints

pacientes/api.py
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:

pacientes/urls.py
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:

pacientes/api.py
@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():

pacientes/api.py
@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:

Rutas publicadas en el esquema OpenAPI
MétodoRutaResumen
GET/api/patientsListar pacientes en formato FHIR
GET/api/patients/{patient_id}Obtener un paciente por su identificador FHIR
GET/api/server/metadataDeclaració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.

07

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:

Colapso medido sobre GENDER_MAP
Valor FHIRCódigos localesCantidad
otherI, N, O, T4
unknownD, P, X3
maleM1
femaleF1
Gráfico 1 · Colapso de códigos Colapso de códigos de género locales sobre valores FHIR Barras horizontales: otros códigos locales convergen en other con cuatro códigos, unknown con tres, male con uno y female con uno. other 4 I, N, O, T unknown 3 D, P, X male 1 M female 1 F
Nueve códigos locales sobre los cuatro valores que admite FHIR R5. Medido sobre GENDER_MAP.

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).

Cuando el estándar no alcanza

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.

08

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.

Bug 01ValidationError

family como lista

incorrecto
name = HumanName(use="official", given=[persona.nombres],
                 family=[persona.primer_apellido])
error real
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.

Bug 02No existe

Imports y métodos que no existen

incorrecto
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"
lo que responde Python
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.

Bug 03Semántica

La cédula declarada como sid/us-ssn

incorrecto
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.

Bug 04Rendimiento

create_fhir_patient llamado dos veces por paciente

incorrecto
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.

Bug 05500 en vez de 404

None como respuesta de un 404

incorrecto
@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.

Bug 06200 vacío

El filtro de género habla otro idioma

incorrecto
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.

Bug 07NameError

models.Q sin importar models

incorrecto
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.

09

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:

PydanticUserError
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:

Matriz de bisección
VarianteResultado
future annotations + @paginate + SchemaFALLA
future annotations + @paginate + esquema importadoFALLA
future annotations + sin @paginate + SchemaOK
sin future annotations + @paginate + SchemaOK
future annotations + @paginate + parámetros escalaresOK

Dos factores, y los dos hacen falta. El mecanismo está en ninja/signature/utils.py:

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:

introspección
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:

introspección
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.

Esquema 3 · Anatomía del fallo Anatomía del fallo entre paginate y anotaciones tardías Con from __future__ import annotations la anotación de filters es el texto PatientFilter; paginate envuelve la función con un wrapper definido en ninja.pagination, y Ninja resuelve el texto contra los globals de ese wrapper, donde PatientFilter no existe, así que queda un ForwardRef irresoluble. 1 · TU FUNCIÓN def list_patients(request, filters: PatientFilter = Query(...)) __annotations__["filters"] == "PatientFilter" ← str, no clase 2 · @paginate LA ENVUELVE view_with_pagination(request, **kwargs) definida dentro de ninja/pagination.py 3 · NINJA RESUELVE EL STRING globalns = call.__globals__ call es el wrapper → globals de ninja.pagination "PatientFilter" in globalns → False ForwardRef("PatientFilter") irresoluble → PydanticUserError
El string se resuelve contra los globals del wrapper, no contra los de tu módulo.
Por qué los escalares sí funcionan

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.

10

Probarlo

Una API de interoperabilidad sin pruebas es una API que rompiste el día que actualizaste fhir.resources.

pacientes/tests/test_api.py
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.

pytest
27 passed in 7.60s

Los códigos que verifiqué

Comportamiento HTTP verificado contra la API en ejecución
PeticiónCódigoContent-Type
GET /api/patients200application/json
GET /api/patients?gender=male200application/json
GET /api/patients/<existente>200application/json
GET /api/patients/<uuid-inexistente>404application/json
GET /api/patients/<no-uuid>404application/json
GET /api/patients/<id>/xml200application/fhir+xml
GET /api/server/metadata200application/json
GET /api/openapi.json200application/json
GET /api/docs200text/html
Detalle que no estaba en el borrador

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.

11

El detalle que más payload ahorra

Mido el mismo paciente serializado de las dos formas:

Medición sobre un Patient con nombre, teléfono, dirección y contacto
ConfiguraciónClavesNulosBytes
exclude_none=False104732.011
exclude_none=True310752
Gráfico 2 · Payload medido Peso del payload con y sin exclude_none Barras de bytes con base cero: sin exclude_none el recurso pesa 2011 bytes y lleva 104 claves, 73 nulas; con exclude_none pesa 752 bytes y lleva 31 claves, ninguna nula. exclude_none = False 2.011 B 104 claves · 73 nulas exclude_none = True 752 B 31 claves · 0 nulas −62,6 % de bytes · 73 claves nulas menos
El mismo recurso con y sin exclude_none. Medido sobre el paciente de prueba.

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(...):

pacientes/api.py
@api.get("/patients", response=list[Patient], exclude_none=True)
@paginate(PageNumberPagination, per_page=25)
def list_patients(request, filters: PatientFilter = Query(...)):
    ...
Ojo con esto

NinjaAPI(exclude_none=True) lanza TypeError. No existe ese keyword en el constructor.

12

Antes de que esto toque producción

Autenticación y autorización. La API del ejemplo está abierta. Antes de exponerla:

pacientes/api.py
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.

Mesa de madera en un puesto de salud rural con una tableta, un cuaderno y un estetoscopio; dos trabajadores de salud al fondo.
Ilustración generada con IA. Ahí aterrizan estos datos: una mesa donde la tableta convive con el cuaderno y el estetoscopio.
FHIR no es privacidad

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.

13

Lo que sigue

El mismo mapper sirve para el resto de recursos:

  • Observation para signos vitales y resultados de laboratorio.
  • Encounter para cada cita.
  • ServiceRequest para la lista de espera.
  • Organization para 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

  1. HumanName.family es string, no lista.
  2. exclude_none=True no es un kwarg de NinjaAPI.
  3. from __future__ import annotations rompe @paginate con esquemas Pydantic.
  4. Devolver None en vez de lanzar Http404 produce un 500, no un 404.
  5. unique=True + blank=True sin null=True revienta 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

Integrar FHIR en Django con Django Ninja y fhir.resources Django 6.1.2 · django-ninja 1.7.1 · fhir.resources 8.3.0 · pydantic 2.14.1 Imágenes ilustrativas generadas con IA





Comentarios

Entradas populares de este blog

Saint Administrativo 7.5, A2 Softway Software y DBisam

Analizando el Siemens Siject 16

WebML, ¿porqué?