release version 2
This commit is contained in:
parent
d9dcc26849
commit
f3ee84b5ca
@ -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
|
||||
VHL_BASE_URL=http://lacpass.create.cl:8182
|
||||
|
||||
29
.gitignore
vendored
29
.gitignore
vendored
@ -1,14 +1,15 @@
|
||||
.env
|
||||
.idea
|
||||
|
||||
temp/*
|
||||
tmp/*
|
||||
*.exe
|
||||
mock_*
|
||||
content-generator
|
||||
*.tfvars
|
||||
.terraform
|
||||
.terraform.lock.hcl
|
||||
templates
|
||||
out
|
||||
debugger
|
||||
.env
|
||||
.idea
|
||||
|
||||
temp/*
|
||||
tmp/*
|
||||
*.exe
|
||||
mock_*
|
||||
content-generator
|
||||
*.tfvars
|
||||
.terraform
|
||||
.terraform.lock.hcl
|
||||
templates
|
||||
out
|
||||
debugger
|
||||
.DS_Store
|
||||
|
||||
18
README.md
18
README.md
@ -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:
|
||||
|
||||
@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
_ "ips-lacpass-backend/pkg/docs"
|
||||
"os"
|
||||
"os/signal"
|
||||
)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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
56
docs/environment.md
Normal 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
2
go.mod
@ -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
4
go.sum
@ -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=
|
||||
|
||||
@ -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)
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
64
internal/ips/client/client.go
Normal file
64
internal/ips/client/client.go
Normal 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"),
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
package fhir
|
||||
package client
|
||||
|
||||
type Bundle struct {
|
||||
ResourceType string `json:"resourceType"`
|
||||
49
internal/ips/core/models.go
Normal file
49
internal/ips/core/models.go
Normal 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"`
|
||||
}
|
||||
291
internal/ips/core/service.go
Normal file
291
internal/ips/core/service.go
Normal 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 ¤t[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
118
internal/ips/handler/ips.go
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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"),
|
||||
}
|
||||
}
|
||||
@ -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"),
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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 {
|
||||
115
internal/users/core/service.go
Normal file
115
internal/users/core/service.go
Normal 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
|
||||
}
|
||||
@ -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,
|
||||
})
|
||||
51
internal/vhl/client/client.go
Normal file
51
internal/vhl/client/client.go
Normal 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"),
|
||||
}
|
||||
}
|
||||
50
internal/vhl/client/models.go
Normal file
50
internal/vhl/client/models.go
Normal 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"`
|
||||
}
|
||||
40
internal/vhl/core/models.go
Normal file
40
internal/vhl/core/models.go
Normal 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"`
|
||||
}
|
||||
80
internal/vhl/core/service.go
Normal file
80
internal/vhl/core/service.go
Normal 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
161
internal/vhl/handler/vhl.go
Normal 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
16
pkg/utils/http_utils.go
Normal 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
43
test/model_test.go
Normal 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
258
test/test_ips.json
Normal 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" }
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user