release version 2

This commit is contained in:
Sergio Peñafiel 2025-08-21 23:47:15 -04:00
parent d9dcc26849
commit f3ee84b5ca
41 changed files with 1588 additions and 538 deletions

View File

@ -1,13 +1,15 @@
API_PORT=3000
KEYCLOAK_URL=http://localhost:9083
KEYCLOAK_REALM=lacpass
KEYCLOAK_CLIENT_ID=app
KEYCLOAK_ADMIN_CLIENT_SECRET=admin-cli-keycloak-client-secret-key
KC_BOOTSTRAP_ADMIN_USERNAME=admin
KC_BOOTSTRAP_ADMIN_PASSWORD=admin
KEYCLOAK_DEFAULT_USER=test
KEYCLOAK_DEFAULT_USER_PASSWORD=test
API_SWAGGER=false
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_USER=keycloak
POSTGRES_PASSWORD=keycloak
KEYCLOAK_HOSTNAME=http://keycloak.lacpass.create.cl
FHIR_BASE_URL=http://lacpass.create.cl:8080
VHL_BASE_URL=http://lacpass.create.cl:8182

1
.gitignore vendored
View File

@ -12,3 +12,4 @@ content-generator
templates
out
debugger
.DS_Store

View File

@ -1,7 +1,7 @@
# PH4H App Backend Template
# IPS Lacpass Backend
Template for backend for the IPS PH4H App. A unified health app for Connectathon users to view, merge, and share IPS data
securely cross-border. This project provides a backend system composed of the IPS PH4H API for handling business logic and
Backend for the IPS Lacpass App. A unified health app for Connectathon users to view, merge, and share IPS data
securely cross-border. This project provides a backend system composed of the IPS Lacpass API for handling business logic and
a Keycloak server for authentication and authorization. The entire stack is containerized using Docker and can be easily
managed with Docker Compose.
@ -12,6 +12,7 @@ managed with Docker Compose.
- [Prerequisites](#prerequisites)
- [Getting Started](#getting-started)
- [Configuration](#configuration)
- [Environment Variables](/docs/environment.md)
- [Running the Application](#running-the-application)
- [Accessing the Services](#accessing-the-services)
- [Keycloak Admin Console](#keycloak-admin-console)
@ -25,7 +26,7 @@ The architecture of this backend system is designed to separate concerns between
## Components
- **IPS PH4H API**: A lightweight, high-performance API that implements the core features of your application.
- **IPS Lacpass API**: A lightweight, high-performance API that implements the core features of your application.
It is protected and requires a valid JWT from an Authorization server to be accessed.
- **Authorization**: Identity and Access Management solution. It handles user registration, login, and token issuance.
@ -39,7 +40,7 @@ The architecture of this backend system is designed to separate concerns between
### Configuration
- [Authentication](/docs/authentication.md)
- IPS PH4H API (WIP)
- IPS Lacpass API (WIP)
### ⚠️ Complete the not implemented calls
@ -53,7 +54,6 @@ To complete these functionalities, search the code for the `TODO: To be implemen
After completing these steps, you can proceed to the next section on running the application.
### Running the Application
Open a terminal in the root directory of the project and run the following command:
@ -64,7 +64,7 @@ Open a terminal in the root directory of the project and run the following comma
This command will:
- Build the Docker image for the IPS PH4H API.
- Build the Docker image for the IPS Lacpass API.
- Pull the official Docker images for Keycloak and Postgres.
- Create and start the containers for all three services.
- Attach your terminal to the logs of all running containers.
@ -83,9 +83,9 @@ Once the services are running, you can access the Keycloak Admin Console to conf
2. You will be redirected to the Keycloak landing page. Click on the **Administration Console** link.
3. Log in with the admin credentials provided in your [configuration](/docs/authentication.md)
### IPS PH4H API
### IPS Lacpass API
IPS PH4H API will be accessible at `http://localhost:9081`. You can use a tool like `curl` or Postman to interact
IPS Lacpass API will be accessible at `http://localhost:9081`. You can use a tool like `curl` or Postman to interact
with your API endpoints. Remember that your API endpoints will be protected by Keycloak, so you will need to obtain a
valid JWT from Keycloak to make successful requests. There is a [helper script](./scripts/auth.sh), where you can request
a token using:

View File

@ -3,6 +3,7 @@ package main
import (
"context"
"fmt"
_ "ips-lacpass-backend/pkg/docs"
"os"
"os/signal"
)

View File

@ -1,25 +1,29 @@
package main
import (
"ips-lacpass-backend/internal/core"
"ips-lacpass-backend/internal/repository/fhir"
"ips-lacpass-backend/internal/repository/keycloak"
"ips-lacpass-backend/internal/repository/vhl"
customMiddleware "ips-lacpass-backend/pkg/middleware"
"log/slog"
"net/http"
"os"
"strings"
"time"
_ "ips-lacpass-backend/internal/docs"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/httplog/v3"
httpSwagger "github.com/swaggo/http-swagger"
"ips-lacpass-backend/internal/handler"
customMiddleware "ips-lacpass-backend/internal/middleware"
userClient "ips-lacpass-backend/internal/users/client"
userCore "ips-lacpass-backend/internal/users/core"
userHandler "ips-lacpass-backend/internal/users/handler"
ipsClient "ips-lacpass-backend/internal/ips/client"
ipsCore "ips-lacpass-backend/internal/ips/core"
ipsHandler "ips-lacpass-backend/internal/ips/handler"
vhlClient "ips-lacpass-backend/internal/vhl/client"
vhlCore "ips-lacpass-backend/internal/vhl/core"
vhlHandler "ips-lacpass-backend/internal/vhl/handler"
)
func (a *App) loadRoutes() {
@ -90,7 +94,7 @@ func (a *App) loadRoutes() {
}
func (a *App) loadUserRoutesNoAuth(router chi.Router) {
r := keycloak.NewKeycloakClient(
r := userClient.NewClient(
a.config.AuthInternalUrl,
a.config.AuthRealm,
a.config.AuthAdminClientID,
@ -99,13 +103,13 @@ func (a *App) loadUserRoutesNoAuth(router chi.Router) {
a.config.AuthEmailClientID,
a.config.AuthEmailLifespan,
)
s := core.NewUserService(r)
h := handler.NewUserHandler(s)
s := userCore.NewService(&r)
h := userHandler.NewHandler(&s)
router.Post("/", h.Create)
}
func (a *App) loadUserRoutesAuth(router chi.Router) {
r := keycloak.NewKeycloakClient(
r := userClient.NewClient(
a.config.AuthInternalUrl,
a.config.AuthRealm,
a.config.AuthAdminClientID,
@ -114,27 +118,23 @@ func (a *App) loadUserRoutesAuth(router chi.Router) {
a.config.AuthEmailClientID,
a.config.AuthEmailLifespan,
)
s := core.NewUserService(r)
h := handler.NewUserHandler(s)
s := userCore.NewService(&r)
h := userHandler.NewHandler(&s)
router.Put("/update", h.Update)
}
func (a *App) loadIpsRoute(router chi.Router) {
r := fhir.FhirRepository{
Client: &http.Client{},
BaseURL: a.config.FhirBaseUrl,
}
s := core.NewFhirService(r)
h := handler.NewIpsHandler(s)
r := ipsClient.NewClient(a.config.FhirBaseUrl)
s := ipsCore.NewService(&r)
h := ipsHandler.NewHandler(&s)
router.Get("/", h.Get)
router.Post("/merge", h.Merge)
}
func (a *App) loadVhlRoute(router chi.Router) {
r := vhl.VhlRepository{
Client: &http.Client{},
BaseURL: a.config.VhlBaseUrl,
}
s := core.NewVhlService(r)
h := handler.NewVhlHandler(s)
r := vhlClient.NewClient(a.config.VhlBaseUrl)
s := vhlCore.NewService(&r)
h := vhlHandler.NewHandler(&s)
router.Post("/", h.Create)
router.Post("/fetch", h.Get)
}

View File

@ -13,9 +13,10 @@ RUN go mod download
COPY ./cmd/api/ ./
COPY ./internal ./internal
COPY ./pkg ./pkg
RUN swag fmt
RUN swag init -o internal/docs
RUN swag init -o pkg/docs
# Go stage to build the API
FROM golang:1.24 as builder

View File

@ -12,11 +12,11 @@ services:
environment:
API_PORT: ${API_PORT:-3000}
AUTH_INTERNAL_URL: ${AUTH_INTERNAL_URL:-http://auth:8080}
AUTH_HOSTNAME: ${AUTH_URL:-http://localhost:9083}
AUTH_REALM: ${AUTH_REALM:-lacpass}
AUTH_HOSTNAME: ${KEYCLOAK_URL:-http://localhost:9083}
AUTH_REALM: ${KEYCLOAK_REALM:-lacpass}
AUTH_CLIENT_ID: ${AUTH_CLIENT_ID:-admin-cli}
# Need to set this after creating a client for Keycloak Admin API access, using service account
AUTH_CLIENT_SECRET: ${AUTH_CLIENT_SECRET:-bbU4vnqhqe2AJ32XpdQVRVqfRMA82Hnu}
AUTH_CLIENT_SECRET: ${KEYCLOAK_ADMIN_CLIENT_SECRET:-bbU4vnqhqe2AJ32XpdQVRVqfRMA82Hnu}
AUTH_EMAIL_REDIRECT_URI: ${AUTH_EMAIL_REDIRECT_URI:-ph4happ://open/validated-email}
AUTH_EMAIL_CLIENT_ID: ${AUTH_EMAIL_CLIENT_ID:-app}
FHIR_BASE_URL: ${FHIR_BASE_URL:-http://lacpass.create.cl:8080}

View File

@ -6,6 +6,8 @@ Lacpass backend uses [Keycloak](https://www.keycloak.org/) as its authentication
cp .env.sample .env
```
(More information about [Enviroment Variables](/docs/environment.md))
Then, to start keycloak we can run it from the root directory with docker compose as:
```bash

56
docs/environment.md Normal file
View File

@ -0,0 +1,56 @@
# Environment Variables
The project multiple containers rely on environment variables stored in `.env` file located in the root folder.
Copy the given template file with the command `cp .env.sample .env` in the root folder, and define the values of each variable according to your setup.
For security reasons, please set obfuscate values for usernames, passwords and secret key variables.
This app uses [Keycloak](https://www.keycloak.org/) to handle OAuth2 user authentication. (more information in [Authentication](docs/authentication.md)),
an so many enviromental variables of this project are set for a Keycloak setup.
Please define the variables of your need if you a different authentication service.
## Definitions
`API_PORT`
Port number where the app runs. Default: `3000`
`KEYCLOAK_URL`
Keycloak service endpoint. Default: `http://localhost:9083`
`KEYCLOAK_REALM`
App users keycloak realm. Used for initial keycloak configuration, if changed, keycloak container and volume must be rebuild. Default: `lacpass`
`KEYCLOAK_CLIENT_ID`
App users keycloak client id. User for initial keycloak configuration, if changed, keycloak container and volume must be rebuild. Default: `app`
`KEYCLOAK_ADMIN_CLIENT_SECRET`
Secret key of the `admin-cli` keycloak client of your instance. No default value since it must be set to the one generated in your Keycloak instance during setup.
`KEYCLOAK_HOSTNAME`
Keycloak service hostname used for flows like password recovery. Default: `http://keycloak.lacpass.create.cl`
`KC_BOOSTRAP_ADMIN_USERNAME`
Keycloak admin console username. Default: `admin`
`KC_BOOTSTRAP_ADMIN_PASSWORD`
Keyclaok admin console password. Default: `admin`
`KEYCLOAK_DEFAULT_USER`
Testing user username. For testing porposes, the app creates a first mock user instance in the keycloak service. Default: `test`
`KEYCLOAK_DEFAULT_USER_PASSWORD`
Testing user password. Default: `test`
`API_SWAGGER`
Enable `/swagger/index.html` endpoint. Boolean value, can be `true` or `false`. Default: `false`
`POSTGRES_USER`
Postgres database default role name. Default: `postgres`
`POSTGRES_PASSWORD`
Postgres database default role password. Default: `postgres`
`FHIR_BASE_URL`
Fhir server endpoint for FHIR IPS managment. Default: `http://lacpass.create.cl:8080`
`VHL_BASE_URL`
VHL server endpoint for VHL QR generation, validation and retrieve. Default: `http://lacpass.create.cl:8182`

2
go.mod
View File

@ -6,7 +6,9 @@ require (
github.com/go-chi/chi/v5 v5.2.1
github.com/go-chi/httplog/v3 v3.2.2
github.com/go-playground/validator/v10 v10.26.0
github.com/google/uuid v1.6.0
github.com/lestrrat-go/jwx/v2 v2.1.6
github.com/mitchellh/mapstructure v1.5.0
github.com/swaggo/http-swagger v1.3.4
github.com/swaggo/swag v1.16.4
)

4
go.sum
View File

@ -29,6 +29,8 @@ github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc
github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@ -51,6 +53,8 @@ github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNB
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=

View File

@ -1,10 +0,0 @@
package core
import (
"context"
)
type UserRepository interface {
CreateUser(ctx context.Context, user User, password string) (*User, error)
UpdateUser(ctx context.Context, userUUID string, ur UserUpdateRequest) (*User, error)
}

View File

@ -1,150 +0,0 @@
package core
import (
"context"
"errors"
"fmt"
customErrors "ips-lacpass-backend/internal/errors"
"ips-lacpass-backend/internal/repository/fhir"
"ips-lacpass-backend/internal/repository/vhl"
"sort"
authMiddleware "ips-lacpass-backend/internal/middleware"
)
type UserService struct {
Repository UserRepository
}
type FhirService struct {
Repository fhir.FhirRepository
}
type VhlService struct {
Repository vhl.VhlRepository
}
func NewUserService(r UserRepository) UserService {
return UserService{
Repository: r,
}
}
func NewFhirService(r fhir.FhirRepository) FhirService {
return FhirService{
Repository: r,
}
}
func NewVhlService(r vhl.VhlRepository) VhlService {
return VhlService{
Repository: r,
}
}
func (us *UserService) CreateUser(ctx context.Context, ur UserRequest) (*User, error) {
user := &User{
Username: ur.Identifier,
Email: ur.Email,
FirstName: ur.FirstName,
LastName: ur.LastName,
Locale: ur.Locale,
DocumentType: ur.DocumentType,
Identifier: ur.Identifier,
}
resp, err := us.Repository.CreateUser(ctx, *user, ur.Password)
if err != nil {
var uErr *customErrors.HttpError
if errors.As(err, &uErr) {
return nil, uErr
}
return nil, &customErrors.HttpError{
StatusCode: 502,
Body: []map[string]interface{}{{"error": "auth_service_error", "message": "Failed to connect to authentication service"}}, Err: err,
}
}
return resp, nil
}
func (us *UserService) UpdateUser(ctx context.Context, ur UserUpdateRequest) (*User, error) {
userUUID, err := authMiddleware.GetUserUUIDFromContext(ctx)
if err != nil {
return nil, &customErrors.HttpError{
StatusCode: 401,
Body: []map[string]interface{}{{"error": "user_uuid_not_found", "message": "User UUID not found in request context"}},
Err: err,
}
}
resp, err := us.Repository.UpdateUser(ctx, userUUID, ur)
if err != nil {
var uErr *customErrors.HttpError
if errors.As(err, &uErr) {
return nil, uErr
}
return nil, &customErrors.HttpError{
StatusCode: 502,
Body: []map[string]interface{}{{"error": "auth_service_error", "message": "Failed to connect to authentication service"}}, Err: err,
}
}
return resp, nil
}
func (fs *FhirService) GetIps(ctx context.Context) (map[string]interface{}, error) {
userId, err := authMiddleware.GetUserDocIDFromContext(ctx)
if err != nil {
return nil, &customErrors.HttpError{
StatusCode: 401,
Body: []map[string]interface{}{{"error": "user_identifier_not_found", "message": "User identifier not found in request context"}},
Err: err,
}
}
bundle, err := fs.Repository.GetDocumentReference(userId)
if err != nil {
fmt.Printf("Error fetching document reference: %v\n", err)
return nil, err
}
entries := bundle.Entry
if len(entries) == 0 {
return nil, &customErrors.HttpError{
StatusCode: 404,
Body: []map[string]interface{}{{"error": "not_found", "message": "No IPS found for the user"}},
Err: fmt.Errorf("no IPS found for the user"),
}
}
sort.Slice(entries, func(i, j int) bool {
return entries[i].Resource.Meta.LastUpdated > entries[j].Resource.Meta.LastUpdated
})
ipsBundle, err := fs.Repository.GetIpsBundle(entries[0].Resource.Content[0].Attachment.URL)
if err != nil {
return nil, err
}
return ipsBundle, nil
}
func (vs *VhlService) CreateQrCode(ctx context.Context, expiresOn *string, content *string, passCode *string) (*vhl.QrData, error) {
if content == nil || *content == "" {
return nil, &customErrors.HttpError{
StatusCode: 400,
Body: []map[string]interface{}{{"error": "invalid_request", "message": "Content cannot be empty"}},
Err: errors.New("content cannot be empty"),
}
}
qrData, err := vs.Repository.CreateQr(ctx, vhl.CreateQrRequest{
JsonContent: *content,
ExpiresOn: *expiresOn,
PassCode: *passCode,
})
if err != nil {
return nil, err
}
return qrData, nil
}

View File

@ -1,70 +0,0 @@
package handler
import (
"encoding/json"
"errors"
"ips-lacpass-backend/internal/core"
errors2 "ips-lacpass-backend/internal/errors"
"net/http"
)
type IpsHandler struct {
FhirService core.FhirService
}
func NewIpsHandler(s core.FhirService) *IpsHandler {
return &IpsHandler{
FhirService: s,
}
}
// GetIPS godoc
//
// @Summary Fetch IPS from national node.
// @Description Fetch IPS from national node using session access token user identifier.
// @Tags IPS FHIR
// @Produce json
//
// @Security ApiKeyAuth
//
// @Success 200 {object} fhir.Bundle
// @Failure 400
// @Failure 404
// @Failure 500
// @Router /ips [get]
func (ih *IpsHandler) Get(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
ips, err := ih.FhirService.GetIps(ctx)
if err != nil {
var httpErr *errors2.HttpError
if errors.As(err, &httpErr) {
res, err := json.Marshal(httpErr.Body)
if err != nil {
http.Error(w, "Failed to encode error response", http.StatusInternalServerError)
return
}
w.WriteHeader(httpErr.StatusCode)
_, err = w.Write(res)
if err != nil {
http.Error(w, "Failed to write response", http.StatusInternalServerError)
return
}
} else {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
return
}
res, err := json.Marshal(ips)
if err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
_, err = w.Write(res)
if err != nil {
http.Error(w, "Failed to write response", http.StatusInternalServerError)
return
}
}

View File

@ -1,94 +0,0 @@
package handler
import (
"encoding/json"
"errors"
"ips-lacpass-backend/internal/core"
customErrors "ips-lacpass-backend/internal/errors"
"net/http"
)
type VhlHandler struct {
VhlService core.VhlService
}
func NewVhlHandler(s core.VhlService) *VhlHandler {
return &VhlHandler{
VhlService: s,
}
}
type VhlRequest struct {
ExpiresOn string `json:"expires_on,omitempty"`
Content string `json:"content,required"`
PassCode string `json:"pass_code,omitempty"`
}
type VhlResponse struct {
Data string `json:"data"`
}
// Create QR data godoc
//
// @Summary Create QR data.
// @Description Create QR data from VHL issuance.
// @Tags IPS FHIR
// @Accept json
// @Produce json
//
// @Param data body VhlRequest true "Data parameters"
//
// @Security ApiKeyAuth
//
// @Success 200 {object} VhlResponse
// @Failure 400
// @Failure 404
// @Failure 500
// @Router /qr [post]
func (vh *VhlHandler) Create(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// TODO check if user is authenticated and has the permission to create a QR code
// TODO throw correct error body
var body VhlRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
qr, err := vh.VhlService.CreateQrCode(ctx, &body.ExpiresOn, &body.Content, &body.PassCode)
if err != nil {
var httpErr *customErrors.HttpError
if errors.As(err, &httpErr) {
res, err := json.Marshal(httpErr.Body)
if err != nil {
http.Error(w, "Failed to encode error response", http.StatusInternalServerError)
return
}
w.WriteHeader(httpErr.StatusCode)
_, err = w.Write(res)
if err != nil {
http.Error(w, "Failed to write response", http.StatusInternalServerError)
return
}
} else {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
return
}
res, err := json.Marshal(&VhlResponse{
Data: qr.Value,
})
if err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
_, err = w.Write(res)
if err != nil {
http.Error(w, "Failed to write response", http.StatusInternalServerError)
return
}
}

View File

@ -0,0 +1,64 @@
package client
import (
"encoding/json"
"fmt"
"io"
"ips-lacpass-backend/pkg/errors"
"ips-lacpass-backend/pkg/utils"
"net/http"
)
type ClientInterface interface {
GetDocumentReference(identifier string) (*Bundle, error)
GetIpsBundle(url string) (map[string]interface{}, error)
}
type IpsClient struct {
Client *http.Client
BaseURL string
}
func NewClient(baseURL string) IpsClient {
return IpsClient{
Client: &http.Client{},
BaseURL: baseURL,
}
}
func request(client *http.Client, req *http.Request) (*http.Response, error) {
resp, err := client.Do(req)
if err != nil {
return nil, &errors.HttpError{
StatusCode: 502,
Body: []map[string]interface{}{{"error": "service_unavailable", "message": "Failed to connect to FHIR service"}},
Err: fmt.Errorf("failed to send request: %w", err),
}
}
if resp.StatusCode != http.StatusOK {
return nil, &errors.HttpError{
StatusCode: resp.StatusCode,
Body: []map[string]interface{}{{"error": "fhir_error", "message": resp.Body}},
}
}
return resp, nil
}
func (c *IpsClient) GetDocumentReference(identifier string) (*Bundle, error) {
// TODO: To be implemented by the participant
return nil, &errors.HttpError{
StatusCode: 500,
Body: []map[string]interface{}{{"error": "Not implemented error", "message": "this method is not implemented yet"}},
Err: fmt.Errorf("failed to get document reference"),
}
}
func (c *IpsClient) GetIpsBundle(url string) (map[string]interface{}, error) {
// TODO: To be implemented by the participant
return nil, &errors.HttpError{
StatusCode: 500,
Body: []map[string]interface{}{{"error": "Not implemented error", "message": "this method is not implemented yet"}},
Err: fmt.Errorf("failed to get document reference"),
}
}

View File

@ -1,4 +1,4 @@
package fhir
package client
type Bundle struct {
ResourceType string `json:"resourceType"`

View File

@ -0,0 +1,49 @@
package core
type Bundle struct {
ID string `json:"id"`
Identifier map[string]interface{} `json:"identifier,omitempty"`
Meta map[string]interface{} `json:"meta,omitempty"`
ResourceType string `json:"resourceType"`
Signature map[string]interface{} `json:"signature,omitempty"`
Timestamp string `json:"timestamp"`
Type string `json:"type"`
Entry []Entry `json:"entry,omitempty"`
}
type Entry struct {
FullURL string `json:"fullUrl"`
Resource map[string]interface{} `json:"resource"` // This could be any FHIR resource, it will be treated as a map
}
type Composition struct {
URL string `json:"url,omitempty"` // This will be here to then convert to Bundle easily
ID string `json:"id"`
ResourceType string `json:"resourceType"`
Text map[string]interface{} `json:"text,omitempty"`
Meta map[string]interface{} `json:"meta,omitempty"`
Status string `json:"status,omitempty"`
Subject map[string]interface{} `json:"subject,omitempty"`
Code map[string]interface{} `json:"code,omitempty"`
Type CodeableConcept `json:"type,omitempty"`
Author []map[string]interface{} `json:"author,omitempty"`
Confidentiality string `json:"confidentiality,omitempty"`
Custodian map[string]interface{} `json:"custodian,omitempty"`
Date string `json:"date,omitempty"`
Section []Section `json:"section,omitempty"`
Title string `json:"title,omitempty"`
}
type CodeableConcept struct {
Coding []map[string]interface{} `json:"coding,omitempty"`
}
type Section struct {
Title string `json:"title,omitempty"`
Code CodeableConcept `json:"code,omitempty"`
Entry []map[string]interface{} `json:"entry,omitempty"`
}
type Reference struct {
Reference string `json:"reference"`
}

View File

@ -0,0 +1,291 @@
package core
import (
"context"
"encoding/json"
"fmt"
"ips-lacpass-backend/internal/ips/client"
customErrors "ips-lacpass-backend/pkg/errors"
authMiddleware "ips-lacpass-backend/pkg/middleware"
"slices"
"sort"
"strings"
"time"
"github.com/google/uuid"
"github.com/mitchellh/mapstructure"
)
type IpsService struct {
Repository *client.IpsClient
}
func NewService(r *client.IpsClient) IpsService {
return IpsService{
Repository: r,
}
}
func (is *IpsService) GetIps(ctx context.Context) (map[string]interface{}, error) {
userId, err := authMiddleware.GetUserDocIDFromContext(ctx)
if err != nil {
return nil, &customErrors.HttpError{
StatusCode: 401,
Body: []map[string]interface{}{{"error": "user_identifier_not_found", "message": "User identifier not found in request context"}},
Err: err,
}
}
bundle, err := is.Repository.GetDocumentReference(userId)
if err != nil {
fmt.Printf("Error fetching document reference: %v\n", err)
return nil, err
}
entries := bundle.Entry
if len(entries) == 0 {
return nil, &customErrors.HttpError{
StatusCode: 404,
Body: []map[string]interface{}{{"error": "not_found", "message": "No IPS found for the user"}},
Err: fmt.Errorf("no IPS found for the user"),
}
}
sort.Slice(entries, func(i, j int) bool {
return entries[i].Resource.Meta.LastUpdated > entries[j].Resource.Meta.LastUpdated
})
ipsBundle, err := is.Repository.GetIpsBundle(entries[0].Resource.Content[0].Attachment.URL)
if err != nil {
return nil, err
}
return ipsBundle, nil
}
// Will return the IPS composition sections
func getIPSComposition(entries []Entry) (*Composition, error) {
i := slices.IndexFunc(entries, func(e Entry) bool {
if e.Resource["resourceType"] != "Composition" {
return false
}
var composition Composition
if err := mapstructure.Decode(e.Resource, &composition); err != nil {
return false
}
return composition.Type.Coding[0]["code"] == "60591-5"
})
if i == -1 {
return nil, fmt.Errorf(`no composition found`)
}
comp := entries[i].Resource
var composition Composition
if err := mapstructure.Decode(comp, &composition); err != nil {
return nil, fmt.Errorf(`error decoding composition: %v`, err)
}
composition.URL = entries[i].FullURL
// Remove empty sections
var result []Section
for _, s := range composition.Section {
if s.Code.Coding != nil {
result = append(result, s)
}
}
composition.Section = result
return &composition, nil
}
func getEntry(reference string, current []Entry, newIpsEntries []Entry) *Entry {
indInCurrent := slices.IndexFunc(current, func(e Entry) bool {
return e.FullURL == reference
})
if indInCurrent != -1 {
return &current[indInCurrent]
}
indInNew := slices.IndexFunc(newIpsEntries, func(e Entry) bool {
return e.FullURL == reference
})
if indInNew != -1 {
return &newIpsEntries[indInNew]
}
return nil
}
func findAllKeysContainingString(m map[string]interface{}, substring string) []string {
var matchingKeys []string
slower := strings.ToLower(substring)
for key := range m {
if strings.Contains(strings.ToLower(key), slower) {
matchingKeys = append(matchingKeys, key)
}
}
return matchingKeys
}
func removeDuplicates(entries []Entry) []Entry {
encountered := map[string]bool{}
var result []Entry
for _, e := range entries {
if !encountered[e.FullURL] {
encountered[e.FullURL] = true
result = append(result, e)
}
}
return result
}
func (is *IpsService) MergeIPS(ctx context.Context, currentIpsBundle map[string]interface{}, newIpsBundle map[string]interface{}) (map[string]interface{}, error) {
var currIPS, newIPS Bundle
if err := mapstructure.Decode(currentIpsBundle, &currIPS); err != nil {
return nil, &customErrors.HttpError{
StatusCode: 400,
Body: []map[string]interface{}{{"error": "bad_request", "message": "Malformed current IPS"}},
Err: fmt.Errorf("malformed current IPS"),
}
}
if err := mapstructure.Decode(newIpsBundle, &newIPS); err != nil {
return nil, &customErrors.HttpError{
StatusCode: 400,
Body: []map[string]interface{}{{"error": "bad_request", "message": "Malformed new IPS"}},
Err: fmt.Errorf("malformed new IPS"),
}
}
curComp, err := getIPSComposition(currIPS.Entry)
if err != nil {
return nil, &customErrors.HttpError{
StatusCode: 400,
Body: []map[string]interface{}{{"error": "bad_request", "message": "Current IPS does not have its composition"}},
Err: err,
}
}
newComp, err := getIPSComposition(newIPS.Entry)
if err != nil {
return nil, &customErrors.HttpError{
StatusCode: 400,
Body: []map[string]interface{}{{"error": "bad_request", "message": "Current IPS does not have its composition"}},
Err: err,
}
}
// Merge composition for IPSs
mergedComp := curComp
for _, section := range newComp.Section {
code := section.Code.Coding[0]["code"]
if code == nil {
continue
}
sectionIndex := slices.IndexFunc(mergedComp.Section, func(s Section) bool {
return len(s.Code.Coding) > 0 && s.Code.Coding[0] != nil && s.Code.Coding[0]["code"] == code
})
if sectionIndex == -1 {
// New IPS section is not present on current IPS
mergedComp.Section = append(mergedComp.Section, section)
} else {
// Sections exists, add entries that do not exist in the current IPS
for _, newEntry := range section.Entry {
exists := false
for _, oldEntry := range mergedComp.Section[sectionIndex].Entry {
if newEntry["reference"] == oldEntry["reference"] {
exists = true
break
}
}
if !exists {
mergedComp.Section[sectionIndex].Entry = append(mergedComp.Section[sectionIndex].Entry, newEntry)
}
}
}
}
fullURL := mergedComp.URL
mergedComp.URL = ""
jsonData, err := json.Marshal(mergedComp)
if err != nil {
return nil, &customErrors.HttpError{
StatusCode: 500,
Body: []map[string]interface{}{{"error": "internal_error", "message": "Failed to convert composition to JSON"}},
Err: err,
}
}
var mergedResource map[string]interface{}
if err := json.Unmarshal(jsonData, &mergedResource); err != nil {
return nil, &customErrors.HttpError{
StatusCode: 500,
Body: []map[string]interface{}{{"error": "internal_error", "message": "Failed to convert composition JSON to a map"}},
Err: err,
}
}
// Build the merge ips with the merge Composition
mergedIPS := Bundle{
ID: uuid.NewString(),
Identifier: currIPS.Identifier,
Meta: currIPS.Meta,
ResourceType: currIPS.ResourceType,
Signature: nil,
Timestamp: time.Now().UTC().String(),
Type: currIPS.Type,
Entry: []Entry{{FullURL: fullURL, Resource: mergedResource}},
}
for _, section := range mergedComp.Section {
for _, secEntry := range section.Entry {
newEntry := getEntry(secEntry["reference"].(string), currIPS.Entry, newIPS.Entry)
if newEntry == nil {
break
}
mergedIPS.Entry = append(mergedIPS.Entry, *newEntry)
if newEntry.Resource == nil {
break
}
// Check for any resource that contains more reference in its representation
// If we find any reference we added it to the IPS
rk := findAllKeysContainingString(newEntry.Resource, "reference")
for _, k := range rk {
v, ok := newEntry.Resource[k]
if !ok {
break
}
var ref Reference
if err := mapstructure.Decode(v, &ref); err != nil {
return nil, fmt.Errorf(`error decoding codeable reference: %v`, err)
}
if ref.Reference != "" {
newEntry = getEntry(ref.Reference, currIPS.Entry, newIPS.Entry)
mergedIPS.Entry = append(mergedIPS.Entry, *newEntry)
}
}
}
}
mergedIPS.Entry = removeDuplicates(mergedIPS.Entry)
jsonData, err = json.Marshal(mergedIPS)
if err != nil {
return nil, &customErrors.HttpError{
StatusCode: 500,
Body: []map[string]interface{}{{"error": "internal_error", "message": "Failed to convert composition to JSON"}},
Err: err,
}
}
var data map[string]interface{}
if err := json.Unmarshal(jsonData, &data); err != nil {
return nil, &customErrors.HttpError{
StatusCode: 500,
Body: []map[string]interface{}{{"error": "internal_error", "message": "Failed to convert composition JSON to a map"}},
Err: err,
}
}
return data, nil
}

118
internal/ips/handler/ips.go Normal file
View File

@ -0,0 +1,118 @@
package handler
import (
"encoding/json"
"errors"
"ips-lacpass-backend/internal/ips/core"
errors2 "ips-lacpass-backend/pkg/errors"
"net/http"
)
type MergeIPSRequest struct {
CurrentIPS map[string]interface{} `json:"current_ips"`
NewIPS map[string]interface{} `json:"new_ips"`
}
type Handler struct {
IpsService *core.IpsService
}
func NewHandler(s *core.IpsService) *Handler {
return &Handler{
IpsService: s,
}
}
// GetIPS godoc
//
// @Summary Fetch IPS from national node.
// @Description Fetch IPS from national node using session access token user identifier.
// @Tags IPS FHIR
// @Produce json
//
// @Security ApiKeyAuth
//
// @Success 200 {object} any
// @Failure 400
// @Failure 404
// @Failure 500
// @Router /ips [get]
func (ih *Handler) Get(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
ips, err := ih.IpsService.GetIps(ctx)
if err != nil {
var httpErr *errors2.HttpError
if errors.As(err, &httpErr) {
res, err := json.Marshal(httpErr.Body)
if err != nil {
http.Error(w, "Failed to encode error response", http.StatusInternalServerError)
return
}
w.WriteHeader(httpErr.StatusCode)
_, err = w.Write(res)
if err != nil {
http.Error(w, "Failed to write response", http.StatusInternalServerError)
return
}
} else {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
return
}
res, err := json.Marshal(ips)
if err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
_, err = w.Write(res)
if err != nil {
http.Error(w, "Failed to write response", http.StatusInternalServerError)
return
}
}
// MergeIPS godoc
//
// @Summary Merge two IPS bundles into a unified IPS.
// @Description Merge two FHIR R4 IPS bundles into a single one, removing reduncancy.
// @Tags IPS FHIR
// @Produce json
//
// @Security ApiKeyAuth
//
// @Param data body MergeIPSRequest true "IPS bundles to merge"
//
// @Success 200 {object} any
// @Failure 400
// @Failure 404
// @Failure 500
// @Router /ips/merge [post]
func (ih *Handler) Merge(w http.ResponseWriter, r *http.Request) {
var body MergeIPSRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
println(err.Error())
w.WriteHeader(http.StatusBadRequest)
return
}
ctx := r.Context()
mi, err := ih.IpsService.MergeIPS(ctx, body.CurrentIPS, body.NewIPS)
if err != nil {
// TODO Do correct error handling
http.Error(w, "Failed to merge IPS", http.StatusInternalServerError)
}
res, err := json.Marshal(mi)
if err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
w.WriteHeader(http.StatusOK)
_, err = w.Write(res)
if err != nil {
http.Error(w, "Failed to write response", http.StatusInternalServerError)
return
}
}

View File

@ -1,30 +0,0 @@
package fhir
import (
"fmt"
"ips-lacpass-backend/internal/errors"
"net/http"
)
type FhirRepository struct {
Client *http.Client
BaseURL string
}
func (c *FhirRepository) GetDocumentReference(identifier string) (*Bundle, error) {
// TODO: To be implemented by the participant
return nil, &errors.HttpError{
StatusCode: 500,
Body: []map[string]interface{}{{"error": "Not implemented error", "message": "this method is not implemented yet"}},
Err: fmt.Errorf("failed to get document reference"),
}
}
func (c *FhirRepository) GetIpsBundle(url string) (map[string]interface{}, error) {
// TODO: To be implemented by the participant
return nil, &errors.HttpError{
StatusCode: 500,
Body: []map[string]interface{}{{"error": "Not implemented error", "message": "this method is not implemented yet"}},
Err: fmt.Errorf("failed to get Ips Bundle"),
}
}

View File

@ -1,22 +0,0 @@
package vhl
import (
"context"
"fmt"
"ips-lacpass-backend/internal/errors"
"net/http"
)
type VhlRepository struct {
Client *http.Client
BaseURL string
}
func (c *VhlRepository) CreateQr(ctx context.Context, body CreateQrRequest) (*QrData, error) {
// TODO: To be implemented by the participant
return nil, &errors.HttpError{
StatusCode: 500,
Body: []map[string]interface{}{{"error": "Not implemented error", "message": "this method is not implemented yet"}},
Err: fmt.Errorf("failed to create VHL QR"),
}
}

View File

@ -1,11 +0,0 @@
package vhl
type CreateQrRequest struct {
ExpiresOn string `json:"expiresOn,omitempty"`
JsonContent string `json:"jsonContent,required"`
PassCode string `json:"passCode,omitempty"`
}
type QrData struct {
Value string
}

View File

@ -1,4 +1,4 @@
package keycloak
package client
import (
"bytes"
@ -6,16 +6,23 @@ import (
"encoding/json"
"fmt"
"io"
"ips-lacpass-backend/internal/core"
"ips-lacpass-backend/internal/errors"
"ips-lacpass-backend/pkg/errors"
"ips-lacpass-backend/pkg/utils"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
func NewKeycloakClient(baseURL, realm, clientId, clientSecret, emailRedirectUri, emailClientId string, emailLifeSpan int) core.UserRepository {
return &Client{
type ClientInterface interface {
SendValidationEmail(ctx context.Context, userID string) error
CreateUser(ctx context.Context, user map[string]interface{}, password string) (*UserID, error)
UpdateUser(ctx context.Context, userUUID string, ur map[string]interface{}) (*UserRegistration, error)
}
func NewClient(baseURL, realm, clientId, clientSecret, emailRedirectUri, emailClientId string, emailLifeSpan int) UserClient {
return UserClient{
Client: &http.Client{},
BaseURL: baseURL,
Realm: realm,
@ -24,10 +31,11 @@ func NewKeycloakClient(baseURL, realm, clientId, clientSecret, emailRedirectUri,
EmailRedirectURI: emailRedirectUri,
EmailClientID: emailClientId,
EmailLifespan: emailLifeSpan,
TokenManager: &TokenManager{},
}
}
func (kc *Client) getAccessToken() (*TokenResponse, error) {
func fetchToken(kc *UserClient) (*TokenResponse, error) {
tokenEndpoint := fmt.Sprintf("%s/realms/%s/protocol/openid-connect/token", kc.BaseURL, kc.Realm)
data := url.Values{}
@ -37,14 +45,13 @@ func (kc *Client) getAccessToken() (*TokenResponse, error) {
resp, err := http.PostForm(tokenEndpoint, data)
if err != nil {
return nil, fmt.Errorf("failed to connect to Keycloak: %w", err)
}
defer func(Body io.ReadCloser) {
err := Body.Close()
if err != nil {
fmt.Errorf("failed to close response body: %s", err.Error())
return nil, &errors.HttpError{
StatusCode: 502,
Body: []map[string]interface{}{{"error": "service_unavailable", "message": "Failed to connect to Keycloak service"}},
Err: err,
}
}(resp.Body)
}
defer utils.CloseBody(resp.Body)
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
@ -55,11 +62,35 @@ func (kc *Client) getAccessToken() (*TokenResponse, error) {
if err := json.NewDecoder(resp.Body).Decode(&token); err != nil {
return nil, fmt.Errorf("failed to decode token response: %w", err)
}
return &token, nil
}
func (kc *Client) sendValidationEmail(ctx context.Context, userID string) error {
func (kc *UserClient) getAccessToken() (string, error) {
kc.TokenManager.mu.RLock()
if time.Now().After(kc.TokenManager.tokenExpiresAt.Add(-1 * time.Minute)) {
kc.TokenManager.mu.RUnlock()
kc.TokenManager.mu.Lock()
defer kc.TokenManager.mu.Unlock()
if time.Now().After(kc.TokenManager.tokenExpiresAt.Add(-1 * time.Minute)) {
token, err := fetchToken(kc)
if err != nil {
return "", fmt.Errorf("failed to get access token: %w", err)
}
kc.TokenManager = &TokenManager{
token: token.AccessToken,
tokenExpiresAt: time.Now().Add(time.Duration(token.ExpiresIn) * time.Second),
}
}
} else {
defer kc.TokenManager.mu.RUnlock()
}
return kc.TokenManager.token, nil
}
func (kc *UserClient) SendValidationEmail(ctx context.Context, userID string) error {
actions := []string{"VERIFY_EMAIL"}
body, err := json.Marshal(actions)
if err != nil {
@ -85,13 +116,17 @@ func (kc *Client) sendValidationEmail(ctx context.Context, userID string) error
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", t.AccessToken))
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", t))
resp, err := kc.Client.Do(req)
if err != nil {
return fmt.Errorf("failed to send request: %w", err)
return &errors.HttpError{
StatusCode: 502,
Body: []map[string]interface{}{{"error": "service_unavailable", "message": "Failed to connect to Auth service"}},
Err: err,
}
}
defer resp.Body.Close()
defer utils.CloseBody(resp.Body)
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
var errorResponse map[string]interface{}
@ -108,17 +143,17 @@ func (kc *Client) sendValidationEmail(ctx context.Context, userID string) error
return nil
}
func (kc *Client) CreateUser(ctx context.Context, user core.User, password string) (*core.User, error) {
func (kc *UserClient) CreateUser(ctx context.Context, user map[string]interface{}, password string) (*UserID, error) {
r := UserRegistrationRequest{
Username: user.Identifier,
Email: user.Email,
FirstName: user.FirstName,
LastName: user.LastName,
Username: user["Identifier"].(string),
Email: user["Email"].(string),
FirstName: user["FirstName"].(string),
LastName: user["LastName"].(string),
Enabled: true,
Attributes: map[string][]string{
"locale": {user.Locale},
"document_type": {string(user.DocumentType)},
"identifier": {user.Identifier},
"locale": {user["Locale"].(string)},
"document_type": {user["DocumentType"].(string)},
"identifier": {user["Identifier"].(string)},
},
Credentials: []UserCredential{
{
@ -145,18 +180,17 @@ func (kc *Client) CreateUser(ctx context.Context, user core.User, password strin
return nil, fmt.Errorf("failed to get access token: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", t.AccessToken))
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", t))
resp, err := kc.Client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to send request: %w", err)
}
defer func(Body io.ReadCloser) {
err := Body.Close()
if err != nil {
fmt.Println("failed to close response body: %v", err)
return nil, &errors.HttpError{
StatusCode: 502,
Body: []map[string]interface{}{{"error": "service_unavailable", "message": "Failed to connect to Auth service"}},
Err: err,
}
}(resp.Body)
}
defer utils.CloseBody(resp.Body)
if resp.StatusCode != http.StatusCreated {
var errorResponse map[string]interface{}
@ -177,30 +211,19 @@ func (kc *Client) CreateUser(ctx context.Context, user core.User, password strin
parts := strings.Split(location, "/")
userID := parts[len(parts)-1]
fmt.Println("New user ID:", userID)
go func() {
newCtx := context.Background()
if err := kc.sendValidationEmail(newCtx, userID); err != nil {
if err := kc.SendValidationEmail(newCtx, userID); err != nil {
// Log the error but don't return it since this is running asynchronously
fmt.Println("Error sending validation email: %v", err)
fmt.Errorf("error sending validation email: %v", err)
}
}()
// Return the first user since we're querying by username/email
return &core.User{
ID: userID,
Username: user.Identifier,
Email: user.Email,
FirstName: user.FirstName,
LastName: user.LastName,
Locale: user.Locale,
DocumentType: user.DocumentType,
Identifier: user.Identifier,
}, nil
return &UserID{ID: userID}, nil
}
func (kc *Client) UpdateUser(ctx context.Context, userUUID string, ur core.UserUpdateRequest) (*core.User, error) {
// Get UserRepresentation from keycloack
func (kc *UserClient) UpdateUser(ctx context.Context, userUUID string, ur map[string]interface{}) (*UserRegistration, error) {
// Get UserRepresentation from Keycloak
ku := fmt.Sprintf("%s/admin/realms/%s/users/%s", kc.BaseURL, kc.Realm, userUUID)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, ku, nil)
if err != nil {
@ -208,17 +231,21 @@ func (kc *Client) UpdateUser(ctx context.Context, userUUID string, ur core.UserU
}
t, err := kc.getAccessToken()
if err != nil {
return nil, fmt.Errorf("failed to get access token: %w", err)
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", t.AccessToken))
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", t))
resp, err := kc.Client.Do(req)
defer func(Body io.ReadCloser) {
err := Body.Close()
if err != nil {
fmt.Println("failed to close response body: %w", err)
if err != nil {
return nil, &errors.HttpError{
StatusCode: 502,
Body: []map[string]interface{}{{"error": "service_unavailable", "message": "Failed to connect to Auth service"}},
Err: err,
}
}(resp.Body)
}
defer utils.CloseBody(resp.Body)
if resp.StatusCode != http.StatusOK {
var errorResponse map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&errorResponse); err != nil {
@ -238,12 +265,12 @@ func (kc *Client) UpdateUser(ctx context.Context, userUUID string, ur core.UserU
// Edit User representation
// For now, only firstName and lastName can be edited
changed := false
if ur.FirstName != userRep.FirstName {
userRep.FirstName = ur.FirstName
if ur["first_name"].(string) != userRep.FirstName {
userRep.FirstName = ur["first_name"].(string)
changed = true
}
if ur.LastName != userRep.LastName {
userRep.LastName = ur.LastName
if ur["last_name"].(string) != userRep.LastName {
userRep.LastName = ur["last_name"].(string)
changed = true
}
if !changed {
@ -264,15 +291,18 @@ func (kc *Client) UpdateUser(ctx context.Context, userUUID string, ur core.UserU
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", t.AccessToken))
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", t))
resp, err = kc.Client.Do(req)
defer func(Body io.ReadCloser) {
err := Body.Close()
if err != nil {
fmt.Println("failed to close response body: %w", err)
if err != nil {
return nil, &errors.HttpError{
StatusCode: 502,
Body: []map[string]interface{}{{"error": "service_unavailable", "message": "Failed to connect to Auth service"}},
Err: err,
}
}(resp.Body)
}
defer utils.CloseBody(resp.Body)
if resp.StatusCode != http.StatusNoContent {
var errorResponse map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&errorResponse); err != nil {
@ -284,14 +314,5 @@ func (kc *Client) UpdateUser(ctx context.Context, userUUID string, ur core.UserU
Err: err,
}
}
return &core.User{
ID: userRep.ID,
Username: userRep.Username,
Email: userRep.Email,
FirstName: ur.FirstName,
LastName: ur.LastName,
Locale: userRep.Attributes["locale"][0],
DocumentType: core.AllowedDocumenTypes[userRep.Attributes["document_type"][0]],
Identifier: userRep.Attributes["identifier"][0],
}, nil
return &userRep, nil
}

View File

@ -1,10 +1,12 @@
package keycloak
package client
import (
"net/http"
"sync"
"time"
)
type Client struct {
type UserClient struct {
Client *http.Client
BaseURL string
Realm string
@ -13,6 +15,13 @@ type Client struct {
EmailRedirectURI string
EmailLifespan int
EmailClientID string
TokenManager *TokenManager
}
type TokenManager struct {
mu sync.RWMutex
token string
tokenExpiresAt time.Time
}
type UserRegistration struct {
@ -25,6 +34,10 @@ type UserRegistration struct {
Credentials []Credential `json:"credentials,omitempty"`
}
type UserID struct {
ID string
}
type Credential struct {
Type string `json:"type"`
Value string `json:"value,omitempty"`
@ -33,11 +46,9 @@ type Credential struct {
}
type TokenResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int `json:"expires_in"`
RefreshExpiresIn int `json:"refresh_expires_in"`
TokenType string `json:"token_type"`
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
TokenType string `json:"token_type"`
}
type UserRegistrationRequest struct {

View File

@ -0,0 +1,115 @@
package core
import (
"context"
"encoding/json"
"errors"
"ips-lacpass-backend/internal/users/client"
customErrors "ips-lacpass-backend/pkg/errors"
authMiddleware "ips-lacpass-backend/pkg/middleware"
)
type ServiceInterface interface {
CreateUser(ctx context.Context, ur UserRequest) (*User, error)
UpdateUser(ctx context.Context, ur UserUpdateRequest) (*User, error)
}
type UserService struct {
Client client.ClientInterface
}
func NewService(r client.ClientInterface) UserService {
return UserService{
Client: r,
}
}
func structToMap(obj interface{}) (map[string]interface{}, error) {
data, err := json.Marshal(obj)
if err != nil {
return nil, err
}
var result map[string]interface{}
if err := json.Unmarshal(data, &result); err != nil {
return nil, err
}
return result, nil
}
func (us *UserService) CreateUser(ctx context.Context, ur UserRequest) (*User, error) {
user := &User{
Username: ur.Identifier,
Email: ur.Email,
FirstName: ur.FirstName,
LastName: ur.LastName,
Locale: ur.Locale,
DocumentType: ur.DocumentType,
Identifier: ur.Identifier,
}
// TODO fix this workaround for cyclic dependency issue
userMap, err := structToMap(user)
if err != nil {
return nil, &customErrors.HttpError{
StatusCode: 500,
Body: []map[string]interface{}{{"error": "internal_error", "message": "Failed to convert user to map"}},
Err: err,
}
}
resp, err := us.Client.CreateUser(ctx, userMap, ur.Password)
if err != nil {
var uErr *customErrors.HttpError
if errors.As(err, &uErr) {
return nil, uErr
}
return nil, &customErrors.HttpError{
StatusCode: 502,
Body: []map[string]interface{}{{"error": "auth_service_error", "message": "Failed to connect to authentication service"}}, Err: err,
}
}
user.ID = resp.ID
return user, nil
}
func (us *UserService) UpdateUser(ctx context.Context, ur UserUpdateRequest) (*User, error) {
userUUID, err := authMiddleware.GetUserUUIDFromContext(ctx)
if err != nil {
return nil, &customErrors.HttpError{
StatusCode: 401,
Body: []map[string]interface{}{{"error": "user_uuid_not_found", "message": "User UUID not found in request context"}},
Err: err,
}
}
updateMap, err := structToMap(ur)
if err != nil {
return nil, &customErrors.HttpError{
StatusCode: 500,
Body: []map[string]interface{}{{"error": "internal_error", "message": "Failed to convert update request to map"}},
Err: err,
}
}
resp, err := us.Client.UpdateUser(ctx, userUUID, updateMap)
if err != nil {
var uErr *customErrors.HttpError
if errors.As(err, &uErr) {
return nil, uErr
}
return nil, &customErrors.HttpError{
StatusCode: 502,
Body: []map[string]interface{}{{"error": "auth_service_error", "message": "Failed to connect to authentication service"}}, Err: err,
}
}
return &User{
Username: resp.ID,
Email: resp.Email,
FirstName: resp.FirstName,
LastName: resp.LastName,
Locale: resp.Attributes["locale"][0],
DocumentType: AllowedDocumenTypes[resp.Attributes["document_type"][0]],
Identifier: resp.ID,
}, nil
}

View File

@ -4,8 +4,8 @@ import (
"encoding/json"
"errors"
"fmt"
"ips-lacpass-backend/internal/core"
errors2 "ips-lacpass-backend/internal/errors"
"ips-lacpass-backend/internal/users/core"
errors2 "ips-lacpass-backend/pkg/errors"
"net/http"
"regexp"
"strings"
@ -13,12 +13,12 @@ import (
"github.com/go-playground/validator/v10"
)
type UserHandler struct {
UserService core.UserService
type Handler struct {
Service core.ServiceInterface
}
func NewUserHandler(s core.UserService) *UserHandler {
return &UserHandler{UserService: s}
func NewHandler(s core.ServiceInterface) *Handler {
return &Handler{Service: s}
}
var matchFirstCap = regexp.MustCompile("(.)([A-Z][a-z]+)")
@ -81,7 +81,7 @@ type userUpdateRequest struct {
// @Failure 404
// @Failure 500
// @Router /users [post]
func (u *UserHandler) Create(w http.ResponseWriter, r *http.Request) {
func (u *Handler) Create(w http.ResponseWriter, r *http.Request) {
var body userCreationRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
w.WriteHeader(http.StatusBadRequest)
@ -138,7 +138,7 @@ func (u *UserHandler) Create(w http.ResponseWriter, r *http.Request) {
w.Write(res)
}
user, err := u.UserService.CreateUser(r.Context(), core.UserRequest{
user, err := u.Service.CreateUser(r.Context(), core.UserRequest{
Email: body.Email,
Password: body.Password,
PasswordConfirm: body.PasswordConfirm,
@ -204,7 +204,7 @@ func (u *UserHandler) Create(w http.ResponseWriter, r *http.Request) {
// @Failure 404
// @Failure 500
// @Router /users/auth/update [put]
func (u *UserHandler) Update(w http.ResponseWriter, r *http.Request) {
func (u *Handler) Update(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
var body userUpdateRequest
@ -237,7 +237,7 @@ func (u *UserHandler) Update(w http.ResponseWriter, r *http.Request) {
return
}
user, err := u.UserService.UpdateUser(r.Context(), core.UserUpdateRequest{
user, err := u.Service.UpdateUser(r.Context(), core.UserUpdateRequest{
FirstName: body.FirstName,
LastName: body.LastName,
})

View File

@ -0,0 +1,51 @@
package client
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"ips-lacpass-backend/pkg/errors"
"ips-lacpass-backend/pkg/utils"
"net/http"
)
type VhlClient struct {
Client *http.Client
BaseURL string
}
func NewClient(baseURL string) VhlClient {
return VhlClient{
Client: &http.Client{},
BaseURL: baseURL,
}
}
func (c *VhlClient) CreateQr(ctx context.Context, body CreateQrRequest) (*QrData, error) {
// TODO: To be implemented by the participant
return nil, &errors.HttpError{
StatusCode: 500,
Body: []map[string]interface{}{{"error": "Not implemented error", "message": "this method is not implemented yet"}},
Err: fmt.Errorf("failed to get document reference"),
}
}
func (c *VhlClient) Validate(ctx context.Context, qrData string) (*QRValidationResponse, error) {
// TODO: To be implemented by the participant
return nil, &errors.HttpError{
StatusCode: 500,
Body: []map[string]interface{}{{"error": "Not implemented error", "message": "this method is not implemented yet"}},
Err: fmt.Errorf("failed to get document reference"),
}
}
func (c *VhlClient) GetIpsUrl(ctx context.Context, shLink string, passCode string) (*VhlManifestResponse, error) {
// TODO: To be implemented by the participant
return nil, &errors.HttpError{
StatusCode: 500,
Body: []map[string]interface{}{{"error": "Not implemented error", "message": "this method is not implemented yet"}},
Err: fmt.Errorf("failed to get document reference"),
}
}

View File

@ -0,0 +1,50 @@
package client
type CreateQrRequest struct {
ExpiresOn string `json:"expiresOn,omitempty"`
JsonContent string `json:"jsonContent,required"`
PassCode string `json:"passCode,omitempty"`
}
type QrData struct {
Value string
}
type QrValidationRequest struct {
QRCodeContent string `json:"qrCodeContent,required"`
}
type ValidationResponseStep struct {
Step string `json:"step,omitempty"`
Status string `json:"status,omitempty"`
Code string `json:"code,omitempty"`
Description string `json:"description,omitempty"`
Error string `json:"error,omitempty"`
}
type ValidationResponseUrl struct {
Url string `json:"url,required"`
Flag string `json:"flag,omitempty"`
Exp int `json:"exp,omitempty"`
Key string `json:"key,omitempty"`
Label string `json:"label,omitempty"`
}
type QRValidationResponse struct {
Status map[string]ValidationResponseStep `json:"status"`
ShLinkContent ValidationResponseUrl `json:"shLinkContent"`
}
type QrIpsRequest struct {
Recipient string `json:"recipient,required"`
PassCode string `json:"passcode,required"`
}
type VhlManifestResponse struct {
Files []VhlManifestResponseFile `json:"files,required"`
}
type VhlManifestResponseFile struct {
ContentType string `json:"contentType,omitempty"`
Location string `json:"location,required"`
}

View File

@ -0,0 +1,40 @@
package core
type DocumentType string
const (
Passport DocumentType = "passport"
Identifier DocumentType = "identifier"
)
var AllowedDocumenTypes = map[string]DocumentType{
"identifier": Identifier,
"passport": Passport,
}
type User struct {
ID string
Username string
Email string
FirstName string
LastName string
Locale string
DocumentType DocumentType
Identifier string
}
type UserRequest struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required"`
PasswordConfirm string `json:"password_confirm" binding:"required"`
FirstName string `json:"first_name" binding:"required"`
LastName string `json:"last_name" binding:"required"`
Locale string `json:"locale" binding:"required"`
DocumentType DocumentType `json:"document_type" binding:"required"`
Identifier string `json:"identifier" binding:"required"`
}
type UserUpdateRequest struct {
FirstName string `json:"first_name" binding:"required"`
LastName string `json:"last_name" binding:"required"`
}

View File

@ -0,0 +1,80 @@
package core
import (
"context"
"errors"
"fmt"
ipsClient "ips-lacpass-backend/internal/ips/client"
"ips-lacpass-backend/internal/vhl/client"
customErrors "ips-lacpass-backend/pkg/errors"
)
type VhlService struct {
Client *client.VhlClient
}
func NewService(r *client.VhlClient) VhlService {
return VhlService{
Client: r,
}
}
func (vs *VhlService) CreateQrCode(ctx context.Context, expiresOn *string, content *string, passCode *string) (*client.QrData, error) {
if content == nil || *content == "" {
return nil, &customErrors.HttpError{
StatusCode: 400,
Body: []map[string]interface{}{{"error": "invalid_request", "message": "Content cannot be empty"}},
Err: errors.New("content cannot be empty"),
}
}
qrData, err := vs.Client.CreateQr(ctx, client.CreateQrRequest{
JsonContent: *content,
ExpiresOn: *expiresOn,
PassCode: *passCode,
})
if err != nil {
return nil, err
}
return qrData, nil
}
func (vs *VhlService) GetQrIps(ctx context.Context, qrData string, passCode string) (map[string]any, error) {
validation, err := vs.Client.Validate(ctx, qrData)
if err != nil {
return nil, err
}
if len(validation.ShLinkContent.Url) == 0 {
return nil, &customErrors.HttpError{
StatusCode: 502,
Body: []map[string]interface{}{{"error": "validation_error", "message": "Validation server did not return a valid access URL"}},
Err: errors.New("content cannot be empty"),
}
}
for step := range validation.Status {
if validation.Status[step].Status != "SUCCESS" {
return nil, &customErrors.HttpError{
StatusCode: 502,
Body: []map[string]interface{}{{"error": "unsuccessful_validation", "message": fmt.Sprintf("Validation step unsuccessful. Code: %s. Description: %s", validation.Status[step].Code, validation.Status[step].Description)}},
Err: errors.New("Unsuccessful Validation"),
}
}
}
ipsFetchUrl, err := vs.Client.GetIpsUrl(ctx, validation.ShLinkContent.Url, passCode)
if len(ipsFetchUrl.Files) == 0 || len(ipsFetchUrl.Files[0].Location) == 0 {
return nil, &customErrors.HttpError{
StatusCode: 502,
Body: []map[string]interface{}{{"error": "invalid_manifest_url", "message": "Manifest server returned invalid bundle url."}},
Err: errors.New("content cannot be empty"),
}
}
ipsClt := ipsClient.NewClient("")
ipsBundle, err := ipsClt.GetIpsBundle(ipsFetchUrl.Files[0].Location)
if err != nil {
return nil, err
}
return ipsBundle, nil
}

161
internal/vhl/handler/vhl.go Normal file
View File

@ -0,0 +1,161 @@
package handler
import (
"encoding/json"
"errors"
"ips-lacpass-backend/internal/vhl/core"
customErrors "ips-lacpass-backend/pkg/errors"
"net/http"
)
type Handler struct {
Service *core.VhlService
}
func NewHandler(s *core.VhlService) *Handler {
return &Handler{
Service: s,
}
}
type VhlRequest struct {
ExpiresOn string `json:"expires_on,omitempty"`
Content string `json:"content,required"`
PassCode string `json:"pass_code,omitempty"`
}
type VhlGetRequest struct {
Data string `json:"data,required"`
PassCode string `json:"pass_code,omitempty"`
}
type VhlResponse struct {
Data string `json:"data"`
}
// Create QR data godoc
//
// @Summary Create QR data.
// @Description Create QR data from VHL issuance.
// @Tags IPS FHIR
// @Accept json
// @Produce json
//
// @Security ApiKeyAuth
//
// @Param data body VhlRequest true "Data parameters"
//
// @Success 200 {object} VhlResponse
// @Failure 400
// @Failure 404
// @Failure 500
// @Router /qr [post]
func (vh *Handler) Create(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// TODO check if user is authenticated and has the permission to create a QR code
// TODO throw correct error body
var body VhlRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
qr, err := vh.Service.CreateQrCode(ctx, &body.ExpiresOn, &body.Content, &body.PassCode)
if err != nil {
var httpErr *customErrors.HttpError
if errors.As(err, &httpErr) {
res, err := json.Marshal(httpErr.Body)
if err != nil {
http.Error(w, "Failed to encode error response", http.StatusInternalServerError)
return
}
w.WriteHeader(httpErr.StatusCode)
_, err = w.Write(res)
if err != nil {
http.Error(w, "Failed to write response", http.StatusInternalServerError)
return
}
} else {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
return
}
res, err := json.Marshal(&VhlResponse{
Data: qr.Value,
})
if err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
_, err = w.Write(res)
if err != nil {
http.Error(w, "Failed to write response", http.StatusInternalServerError)
return
}
}
// Get IPS Bundle from valid QR data godoc
//
// @Summary Get IPS Bundle with valid VHL QR.
// @Description Get IPS Bundle using a valid VHL QR.
// @Tags IPS FHIR
// @Accept json
// @Produce json
//
// @Security ApiKeyAuth
//
// @Param data body VhlGetRequest true "Data parameters"
//
// @Success 200 {object} any
// @Failure 400
// @Failure 404
// @Failure 500
// @Router /qr/fetch [post]
func (vh *Handler) Get(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// TODO throw correct error body
var body VhlGetRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
ips, err := vh.Service.GetQrIps(ctx, body.Data, body.PassCode)
if err != nil {
var httpErr *customErrors.HttpError
if errors.As(err, &httpErr) {
res, err := json.Marshal(httpErr.Body)
if err != nil {
http.Error(w, "Failed to encode error response", http.StatusInternalServerError)
return
}
w.WriteHeader(httpErr.StatusCode)
_, err = w.Write(res)
if err != nil {
http.Error(w, "Failed to write response", http.StatusInternalServerError)
return
}
} else {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
return
}
res, err := json.Marshal(ips)
if err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
_, err = w.Write(res)
if err != nil {
http.Error(w, "Failed to write response", http.StatusInternalServerError)
return
}
}

16
pkg/utils/http_utils.go Normal file
View File

@ -0,0 +1,16 @@
package utils
import (
"fmt"
"io"
)
func CloseBody(Body io.ReadCloser) {
if Body == nil {
return
}
err := Body.Close()
if err != nil {
fmt.Println("failed to close response body: %w", err)
}
}

43
test/model_test.go Normal file
View File

@ -0,0 +1,43 @@
package test
import (
"encoding/json"
"ips-lacpass-backend/internal/ips/core"
"os"
"reflect"
"testing"
)
func TestBundle_Deserialization(t *testing.T) {
content, _ := os.ReadFile("test_ips.json")
var bundle core.Bundle
err := json.Unmarshal(content, &bundle)
if err != nil {
t.Fatalf("Failed to unmarshal sample JSON: %v", err)
}
remarshaledJSON, err := json.Marshal(bundle)
if err != nil {
t.Fatalf("Failed to marshal bundle struct back to JSON: %v", err)
}
var originalMap, remarshaledMap map[string]interface{}
err = json.Unmarshal(content, &originalMap)
if err != nil {
t.Fatalf("Failed to unmarshal original JSON to map: %v", err)
}
err = json.Unmarshal(remarshaledJSON, &remarshaledMap)
if err != nil {
t.Fatalf("Failed to unmarshal remarshaled JSON to map: %v", err)
}
if !reflect.DeepEqual(originalMap, remarshaledMap) {
originalPretty, _ := json.MarshalIndent(originalMap, "", " ")
remarshaledPretty, _ := json.MarshalIndent(remarshaledMap, "", " ")
t.Errorf("Serialized JSON is not equal to the original.\nOriginal:\n%s\n\nRemarshaled:\n%s", string(originalPretty), string(remarshaledPretty))
}
}

258
test/test_ips.json Normal file
View File

@ -0,0 +1,258 @@
{
"resourceType": "Bundle",
"signature": {
"data": "MEQCIBcX/Ne1VY92x6w+NXaCoPPC7qV73PFswrU/z6Gb+cIMAiBYp/kjDBjJjwZEOOtlOzV2Gf9U34bFqGr6/MzNuHo7EA==",
"type": [
{
"code": "1.2.840.10065.1.12.1.5",
"system": "urn:iso-astm:E1762-95:2013"
}
],
"when": "2024-09-09T14:07:12.970Z",
"who": {
"identifier": {
"value": "LACPass"
}
}
},
"id": "ips-example-comprehensive",
"identifier": {
"system": "urn:ietf:rfc:4122",
"value": "1"
},
"meta": {
"profile": ["http://hl7.org/fhir/uv/ips/StructureDefinition/Bundle-ips"]
},
"type": "document",
"timestamp": "2025-08-02T09:38:15Z",
"entry": [
{
"fullUrl": "urn:uuid:0d648439-91a9-425b-8660-b084b5c7774d",
"resource": {
"resourceType": "Composition",
"id": "ips-composition",
"status": "final",
"type": {
"coding": [
{
"system": "http://loinc.org",
"code": "60591-5",
"display": "Patient summary Document"
}
]
},
"subject": { "reference": "urn:uuid:ab5b5333-5735-4299-a86d-375b3648115a" },
"date": "2025-08-02T09:38:15Z",
"author": [{ "reference": "urn:uuid:1e345678-90ab-cdef-1234-567890abcdef" }],
"title": "International Patient Summary for Jane Doe",
"section": [
{
"title": "Allergies and Intolerances",
"code": { "coding": [{ "system": "http://loinc.org", "code": "48765-2" }] },
"entry": [{ "reference": "urn:uuid:2b1f8c8e-5b23-44b8-b5e1-a9d9b6d8f3e5" }]
},
{
"title": "Medication Summary",
"code": { "coding": [{ "system": "http://loinc.org", "code": "10160-0" }] },
"entry": [{ "reference": "urn:uuid:3c2g7d7d-6c34-55c9-c6f2-b0e0c7e9g4f6" }]
},
{
"title": "Problem List",
"code": { "coding": [{ "system": "http://loinc.org", "code": "11450-4" }] },
"entry": [{ "reference": "urn:uuid:4d3h6e6e-7d45-66d0-d7g3-c1f1d8f0h5g7" }]
},
{
"title": "Immunizations",
"code": { "coding": [{ "system": "http://loinc.org", "code": "11369-6" }] },
"entry": [{ "reference": "urn:uuid:5e4i5f5f-8e56-77e1-e8h4-d2g2e9g1i6h8" }]
},
{
"title": "History of Procedures",
"code": { "coding": [{ "system": "http://loinc.org", "code": "47519-4" }] },
"entry": [{ "reference": "urn:uuid:6f5j4g4g-9f67-88f2-f9i5-e3h3f0h2j7i9" }]
},
{
"title": "Medical Devices",
"code": { "coding": [{ "system": "http://hl7.org/fhir/uv/ips/CodeSystem/absent-unknown-uv-ips", "code": "medication-and-device-use" }] },
"entry": [{ "reference": "urn:uuid:7g6k3h3h-0g78-99g3-g0j6-f4i4g1i3k8j0" }]
},
{
"title": "Diagnostic Results",
"code": { "coding": [{ "system": "http://loinc.org", "code": "30954-2" }] },
"entry": [{ "reference": "urn:uuid:8h7l2i2i-1h89-00h4-h1k7-g5j5h2j4l9k1" }]
},
{
"title": "Vital Signs",
"code": { "coding": [{ "system": "http://loinc.org", "code": "8716-3" }] },
"entry": [{ "reference": "urn:uuid:9i8m1j1j-2i90-11i5-i2l8-h6k6i3k5m0l2" }]
},
{
"title": "Past History of Illness",
"code": { "coding": [{ "system": "http://loinc.org", "code": "11348-0" }] },
"entry": [{ "reference": "urn:uuid:a1b2c3d4-e5f6-7890-1234-567890abcdef" }]
},
{
"title": "Pregnancy Status",
"code": { "coding": [{ "system": "http://loinc.org", "code": "82810-3" }] },
"entry": [{ "reference": "urn:uuid:b2c3d4e5-f6a7-8901-2345-67890abcdef1" }]
},
{
"title": "Social History",
"code": { "coding": [{ "system": "http://loinc.org", "code": "29762-2" }] },
"entry": [{ "reference": "urn:uuid:c3d4e5f6-a7b8-9012-3456-7890abcdef12" }]
},
{
"title": "Advance Directives",
"code": { "coding": [{ "system": "http://loinc.org", "code": "42348-3" }] },
"entry": [{ "reference": "urn:uuid:d4e5f6a7-b8c9-0123-4567-890abcdef123" }]
},
{
"title": "Functional Status",
"code": { "coding": [{ "system": "http://loinc.org", "code": "47420-5" }] },
"entry": [{ "reference": "urn:uuid:e5f6a7b8-c9d0-1234-5678-90abcdef1234" }]
},
{
"title": "Plan of Care",
"code": { "coding": [{ "system": "http://loinc.org", "code": "18776-5" }] },
"entry": [{ "reference": "urn:uuid:f6a7b8c9-d0e1-2345-6789-0abcdef12345" }]
}
]
}
},
{
"fullUrl": "urn:uuid:ab5b5333-5735-4299-a86d-375b3648115a",
"resource": {
"resourceType": "Patient", "id": "patient-jane-doe",
"name": [{"family": "Doe", "given": ["Jane"]}],
"gender": "female", "birthDate": "1985-02-20"
}
},
{
"fullUrl": "urn:uuid:1e345678-90ab-cdef-1234-567890abcdef",
"resource": { "resourceType": "Practitioner", "id": "practitioner-dr-smith", "name": [{"family": "Smith", "given": ["John"], "prefix": ["Dr"]}] }
},
{
"fullUrl": "urn:uuid:2b1f8c8e-5b23-44b8-b5e1-a9d9b6d8f3e5",
"resource": {
"resourceType": "AllergyIntolerance", "id": "allergy-penicillin",
"clinicalStatus": { "coding": [{ "system": "http://terminology.hl7.org/CodeSystem/allergyintolerance-clinical", "code": "active" }] },
"verificationStatus": { "coding": [{ "system": "http://terminology.hl7.org/CodeSystem/allergyintolerance-verification", "code": "confirmed" }] },
"code": { "coding": [{ "system": "http://snomed.info/sct", "code": "373270004", "display": "Allergy to penicillin" }] },
"patient": { "reference": "urn:uuid:ab5b5333-5735-4299-a86d-375b3648115a" }
}
},
{
"fullUrl": "urn:uuid:3c2g7d7d-6c34-55c9-c6f2-b0e0c7e9g4f6",
"resource": {
"resourceType": "MedicationStatement", "id": "med-lisinopril",
"status": "active",
"medicationCodeableConcept": { "coding": [{ "system": "http://www.nlm.nih.gov/research/umls/rxnorm", "code": "203632", "display": "Lisinopril 10 MG Oral Tablet" }] },
"subject": { "reference": "urn:uuid:ab5b5333-5735-4299-a86d-375b3648115a" }
}
},
{
"fullUrl": "urn:uuid:4d3h6e6e-7d45-66d0-d7g3-c1f1d8f0h5g7",
"resource": {
"resourceType": "Condition", "id": "problem-hypertension",
"clinicalStatus": { "coding": [{ "system": "http://terminology.hl7.org/CodeSystem/condition-clinical", "code": "active" }] },
"verificationStatus": { "coding": [{ "system": "http://terminology.hl7.org/CodeSystem/condition-ver-status", "code": "confirmed" }] },
"code": { "coding": [{ "system": "http://snomed.info/sct", "code": "38341003", "display": "Hypertensive disorder" }] },
"subject": { "reference": "urn:uuid:ab5b5333-5735-4299-a86d-375b3648115a" }
}
},
{
"fullUrl": "urn:uuid:5e4i5f5f-8e56-77e1-e8h4-d2g2e9g1i6h8",
"resource": {
"resourceType": "Immunization", "id": "imm-covid19", "status": "completed",
"vaccineCode": { "coding": [{ "system": "http://hl7.org/fhir/sid/cvx", "code": "208", "display": "SARS-COV-2 (COVID-19) vaccine, mRNA, LNP, spike protein, preservative free, 100 mcg/0.5mL dose" }] },
"patient": { "reference": "urn:uuid:ab5b5333-5735-4299-a86d-375b3648115a" }, "occurrenceDateTime": "2024-12-15"
}
},
{
"fullUrl": "urn:uuid:6f5j4g4g-9f67-88f2-f9i5-e3h3f0h2j7i9",
"resource": {
"resourceType": "Procedure", "id": "proc-appendectomy", "status": "completed",
"code": { "coding": [{ "system": "http://snomed.info/sct", "code": "80146002", "display": "Appendectomy" }] },
"subject": { "reference": "urn:uuid:ab5b5333-5735-4299-a86d-375b3648115a" }, "performedDateTime": "2015-06-10"
}
},
{
"fullUrl": "urn:uuid:7g6k3h3h-0g78-99g3-g0j6-f4i4g1i3k8j0",
"resource": {
"resourceType": "DeviceUseStatement", "id": "dev-pacemaker", "status": "active",
"device": { "reference": { "display": "Implanted Pacemaker" } },
"subject": { "reference": "urn:uuid:ab5b5333-5735-4299-a86d-375b3648115a" }
}
},
{
"fullUrl": "urn:uuid:8h7l2i2i-1h89-00h4-h1k7-g5j5h2j4l9k1",
"resource": {
"resourceType": "Observation", "id": "obs-hba1c", "status": "final",
"code": { "coding": [{ "system": "http://loinc.org", "code": "4548-4", "display": "Hemoglobin A1c/Hemoglobin.total in Blood" }] },
"subject": { "reference": "urn:uuid:ab5b5333-5735-4299-a86d-375b3648115a" }, "valueQuantity": { "value": 5.7, "unit": "%", "system": "http://unitsofmeasure.org", "code": "%" }
}
},
{
"fullUrl": "urn:uuid:9i8m1j1j-2i90-11i5-i2l8-h6k6i3k5m0l2",
"resource": {
"resourceType": "Observation", "id": "obs-bp", "status": "final",
"code": { "coding": [{ "system": "http://loinc.org", "code": "85354-9", "display": "Blood pressure panel with all children optional" }] },
"subject": { "reference": "urn:uuid:ab5b5333-5735-4299-a86d-375b3648115a" },
"component": [
{"code": { "coding": [{"system": "http://loinc.org", "code": "8480-6"}]}, "valueQuantity": {"value": 120, "unit": "mm[Hg]"}},
{"code": { "coding": [{"system": "http://loinc.org", "code": "8462-4"}]}, "valueQuantity": {"value": 80, "unit": "mm[Hg]"}}
]
}
},
{
"fullUrl": "urn:uuid:a1b2c3d4-e5f6-7890-1234-567890abcdef",
"resource": {
"resourceType": "Condition", "id": "past-illness-pneumonia", "clinicalStatus": { "coding": [{ "system": "http://terminology.hl7.org/CodeSystem/condition-clinical", "code": "resolved" }] },
"code": { "coding": [{ "system": "http://snomed.info/sct", "code": "233604007", "display": "Pneumonia" }] },
"subject": { "reference": "urn:uuid:ab5b5333-5735-4299-a86d-375b3648115a" }, "onsetDateTime": "2010-03-01", "abatementDateTime": "2010-03-20"
}
},
{
"fullUrl": "urn:uuid:b2c3d4e5-f6a7-8901-2345-67890abcdef1",
"resource": {
"resourceType": "Observation", "id": "obs-preg-no", "status": "final",
"code": { "coding": [{"system": "http://loinc.org", "code": "82810-3", "display": "Pregnancy status"}] },
"subject": { "reference": "urn:uuid:ab5b5333-5735-4299-a86d-375b3648115a" }, "valueCodeableConcept": {"coding": [{"system": "http://snomed.info/sct", "code": "261665006", "display": "Not pregnant"}]}
}
},
{
"fullUrl": "urn:uuid:c3d4e5f6-a7b8-9012-3456-7890abcdef12",
"resource": {
"resourceType": "Observation", "id": "obs-smoke", "status": "final",
"code": { "coding": [{"system": "http://loinc.org", "code": "72166-2", "display": "Tobacco smoking status"}] },
"subject": { "reference": "urn:uuid:ab5b5333-5735-4299-a86d-375b3648115a" }, "valueCodeableConcept": {"coding": [{"system": "http://snomed.info/sct", "code": "266919005", "display": "Never smoked"}]}
}
},
{
"fullUrl": "urn:uuid:d4e5f6a7-b8c9-0123-4567-890abcdef123",
"resource": {
"resourceType": "Consent", "id": "consent-dnr", "status": "active",
"scope": {"coding": [{"system": "http://terminology.hl7.org/CodeSystem/consentscope", "code": "adr"}]},
"category": [{"coding": [{"system": "http://loinc.org", "code": "57016-8"}]}],
"patient": { "reference": "urn:uuid:ab5b5333-5735-4299-a86d-375b3648115a" },
"policyRule": { "coding": [{"system": "http://terminology.hl7.org/CodeSystem/consentpolicydeident", "code": "dnr"}] }
}
},
{
"fullUrl": "urn:uuid:e5f6a7b8-c9d0-1234-5678-90abcdef1234",
"resource": {
"resourceType": "ClinicalImpression", "id": "func-status-independent", "status": "completed",
"description": "Patient is fully independent in activities of daily living.",
"subject": { "reference": "urn:uuid:ab5b5333-5735-4299-a86d-375b3648115a" }
}
},
{
"fullUrl": "urn:uuid:f6a7b8c9-d0e1-2345-6789-0abcdef12345",
"resource": {
"resourceType": "CarePlan", "id": "careplan-hypertension", "status": "active", "intent": "plan",
"description": "Continue Lisinopril 10mg daily. Monitor blood pressure weekly. Follow-up in 3 months.",
"subject": { "reference": "urn:uuid:ab5b5333-5735-4299-a86d-375b3648115a" }
}
}
]
}