From f3ee84b5ca90965a86468d46bf9dd7aa1cf88d9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Pe=C3=B1afiel?= Date: Thu, 21 Aug 2025 23:47:15 -0400 Subject: [PATCH] release version 2 --- .env.sample | 8 +- .gitignore | 29 +- README.md | 18 +- cmd/api/main.go | 1 + cmd/api/routes.go | 52 ++-- docker/Dockerfile | 3 +- docker/compose.yaml | 6 +- docs/authentication.md | 2 + docs/environment.md | 56 ++++ go.mod | 2 + go.sum | 4 + internal/core/outbound.go | 10 - internal/core/service.go | 150 --------- internal/handler/ips.go | 70 ----- internal/handler/vhl.go | 94 ------ internal/ips/client/client.go | 64 ++++ .../{repository/fhir => ips/client}/models.go | 2 +- internal/ips/core/models.go | 49 +++ internal/ips/core/service.go | 291 ++++++++++++++++++ internal/ips/handler/ips.go | 118 +++++++ internal/repository/fhir/client.go | 30 -- internal/repository/vhl/client.go | 22 -- internal/repository/vhl/models.go | 11 - .../keycloak => users/client}/client.go | 175 ++++++----- .../keycloak => users/client}/models.go | 25 +- internal/{ => users}/core/models.go | 0 internal/users/core/service.go | 115 +++++++ internal/{ => users}/handler/user.go | 20 +- internal/vhl/client/client.go | 51 +++ internal/vhl/client/models.go | 50 +++ internal/vhl/core/models.go | 40 +++ internal/vhl/core/service.go | 80 +++++ internal/vhl/handler/vhl.go | 161 ++++++++++ {internal => pkg}/docs/docs.go | 0 {internal => pkg}/docs/swagger.json | 0 {internal => pkg}/docs/swagger.yaml | 0 {internal => pkg}/errors/errors.go | 0 {internal => pkg}/middleware/auth.go | 0 pkg/utils/http_utils.go | 16 + test/model_test.go | 43 +++ test/test_ips.json | 258 ++++++++++++++++ 41 files changed, 1588 insertions(+), 538 deletions(-) create mode 100644 docs/environment.md delete mode 100644 internal/core/outbound.go delete mode 100644 internal/core/service.go delete mode 100644 internal/handler/ips.go delete mode 100644 internal/handler/vhl.go create mode 100644 internal/ips/client/client.go rename internal/{repository/fhir => ips/client}/models.go (99%) create mode 100644 internal/ips/core/models.go create mode 100644 internal/ips/core/service.go create mode 100644 internal/ips/handler/ips.go delete mode 100644 internal/repository/fhir/client.go delete mode 100644 internal/repository/vhl/client.go delete mode 100644 internal/repository/vhl/models.go rename internal/{repository/keycloak => users/client}/client.go (61%) rename internal/{repository/keycloak => users/client}/models.go (80%) rename internal/{ => users}/core/models.go (100%) create mode 100644 internal/users/core/service.go rename internal/{ => users}/handler/user.go (93%) create mode 100644 internal/vhl/client/client.go create mode 100644 internal/vhl/client/models.go create mode 100644 internal/vhl/core/models.go create mode 100644 internal/vhl/core/service.go create mode 100644 internal/vhl/handler/vhl.go rename {internal => pkg}/docs/docs.go (100%) rename {internal => pkg}/docs/swagger.json (100%) rename {internal => pkg}/docs/swagger.yaml (100%) rename {internal => pkg}/errors/errors.go (100%) rename {internal => pkg}/middleware/auth.go (100%) create mode 100644 pkg/utils/http_utils.go create mode 100644 test/model_test.go create mode 100644 test/test_ips.json diff --git a/.env.sample b/.env.sample index f5b9e61..734a67b 100644 --- a/.env.sample +++ b/.env.sample @@ -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 \ No newline at end of file +VHL_BASE_URL=http://lacpass.create.cl:8182 diff --git a/.gitignore b/.gitignore index dd2638b..00f4323 100644 --- a/.gitignore +++ b/.gitignore @@ -1,14 +1,15 @@ -.env -.idea - -temp/* -tmp/* -*.exe -mock_* -content-generator -*.tfvars -.terraform -.terraform.lock.hcl -templates -out -debugger \ No newline at end of file +.env +.idea + +temp/* +tmp/* +*.exe +mock_* +content-generator +*.tfvars +.terraform +.terraform.lock.hcl +templates +out +debugger +.DS_Store diff --git a/README.md b/README.md index fcca9f9..5bcf26e 100644 --- a/README.md +++ b/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: diff --git a/cmd/api/main.go b/cmd/api/main.go index 439830d..9237aaa 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -3,6 +3,7 @@ package main import ( "context" "fmt" + _ "ips-lacpass-backend/pkg/docs" "os" "os/signal" ) diff --git a/cmd/api/routes.go b/cmd/api/routes.go index 08eb92d..bcb9941 100644 --- a/cmd/api/routes.go +++ b/cmd/api/routes.go @@ -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) } diff --git a/docker/Dockerfile b/docker/Dockerfile index dd248aa..8240b77 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -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 diff --git a/docker/compose.yaml b/docker/compose.yaml index b51c4b8..2569c27 100644 --- a/docker/compose.yaml +++ b/docker/compose.yaml @@ -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} diff --git a/docs/authentication.md b/docs/authentication.md index b9039e5..3aa7181 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -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 diff --git a/docs/environment.md b/docs/environment.md new file mode 100644 index 0000000..1a37384 --- /dev/null +++ b/docs/environment.md @@ -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` diff --git a/go.mod b/go.mod index ba614e8..4097f05 100644 --- a/go.mod +++ b/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 ) diff --git a/go.sum b/go.sum index f2c0008..784b477 100644 --- a/go.sum +++ b/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= diff --git a/internal/core/outbound.go b/internal/core/outbound.go deleted file mode 100644 index 92f459c..0000000 --- a/internal/core/outbound.go +++ /dev/null @@ -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) -} diff --git a/internal/core/service.go b/internal/core/service.go deleted file mode 100644 index 5d82ff4..0000000 --- a/internal/core/service.go +++ /dev/null @@ -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 -} diff --git a/internal/handler/ips.go b/internal/handler/ips.go deleted file mode 100644 index a2accdc..0000000 --- a/internal/handler/ips.go +++ /dev/null @@ -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 - } -} diff --git a/internal/handler/vhl.go b/internal/handler/vhl.go deleted file mode 100644 index 45f361d..0000000 --- a/internal/handler/vhl.go +++ /dev/null @@ -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 - } -} diff --git a/internal/ips/client/client.go b/internal/ips/client/client.go new file mode 100644 index 0000000..697fbb2 --- /dev/null +++ b/internal/ips/client/client.go @@ -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"), + } +} diff --git a/internal/repository/fhir/models.go b/internal/ips/client/models.go similarity index 99% rename from internal/repository/fhir/models.go rename to internal/ips/client/models.go index 1467507..947bcae 100644 --- a/internal/repository/fhir/models.go +++ b/internal/ips/client/models.go @@ -1,4 +1,4 @@ -package fhir +package client type Bundle struct { ResourceType string `json:"resourceType"` diff --git a/internal/ips/core/models.go b/internal/ips/core/models.go new file mode 100644 index 0000000..deb517f --- /dev/null +++ b/internal/ips/core/models.go @@ -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"` +} diff --git a/internal/ips/core/service.go b/internal/ips/core/service.go new file mode 100644 index 0000000..8525197 --- /dev/null +++ b/internal/ips/core/service.go @@ -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 +} diff --git a/internal/ips/handler/ips.go b/internal/ips/handler/ips.go new file mode 100644 index 0000000..b0fc31e --- /dev/null +++ b/internal/ips/handler/ips.go @@ -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 + } +} diff --git a/internal/repository/fhir/client.go b/internal/repository/fhir/client.go deleted file mode 100644 index fef2b82..0000000 --- a/internal/repository/fhir/client.go +++ /dev/null @@ -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"), - } -} diff --git a/internal/repository/vhl/client.go b/internal/repository/vhl/client.go deleted file mode 100644 index 9d7e70d..0000000 --- a/internal/repository/vhl/client.go +++ /dev/null @@ -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"), - } -} diff --git a/internal/repository/vhl/models.go b/internal/repository/vhl/models.go deleted file mode 100644 index 7ec5b7a..0000000 --- a/internal/repository/vhl/models.go +++ /dev/null @@ -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 -} diff --git a/internal/repository/keycloak/client.go b/internal/users/client/client.go similarity index 61% rename from internal/repository/keycloak/client.go rename to internal/users/client/client.go index 5cc4eab..2e78325 100644 --- a/internal/repository/keycloak/client.go +++ b/internal/users/client/client.go @@ -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 } diff --git a/internal/repository/keycloak/models.go b/internal/users/client/models.go similarity index 80% rename from internal/repository/keycloak/models.go rename to internal/users/client/models.go index 494c2a8..5db23b7 100644 --- a/internal/repository/keycloak/models.go +++ b/internal/users/client/models.go @@ -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 { diff --git a/internal/core/models.go b/internal/users/core/models.go similarity index 100% rename from internal/core/models.go rename to internal/users/core/models.go diff --git a/internal/users/core/service.go b/internal/users/core/service.go new file mode 100644 index 0000000..d17a1cd --- /dev/null +++ b/internal/users/core/service.go @@ -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 +} diff --git a/internal/handler/user.go b/internal/users/handler/user.go similarity index 93% rename from internal/handler/user.go rename to internal/users/handler/user.go index 1c0e32a..d69b451 100644 --- a/internal/handler/user.go +++ b/internal/users/handler/user.go @@ -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, }) diff --git a/internal/vhl/client/client.go b/internal/vhl/client/client.go new file mode 100644 index 0000000..a5ee602 --- /dev/null +++ b/internal/vhl/client/client.go @@ -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"), + } +} diff --git a/internal/vhl/client/models.go b/internal/vhl/client/models.go new file mode 100644 index 0000000..177bd54 --- /dev/null +++ b/internal/vhl/client/models.go @@ -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"` +} diff --git a/internal/vhl/core/models.go b/internal/vhl/core/models.go new file mode 100644 index 0000000..d655de1 --- /dev/null +++ b/internal/vhl/core/models.go @@ -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"` +} diff --git a/internal/vhl/core/service.go b/internal/vhl/core/service.go new file mode 100644 index 0000000..0f1c305 --- /dev/null +++ b/internal/vhl/core/service.go @@ -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 +} diff --git a/internal/vhl/handler/vhl.go b/internal/vhl/handler/vhl.go new file mode 100644 index 0000000..cebc252 --- /dev/null +++ b/internal/vhl/handler/vhl.go @@ -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 + } +} diff --git a/internal/docs/docs.go b/pkg/docs/docs.go similarity index 100% rename from internal/docs/docs.go rename to pkg/docs/docs.go diff --git a/internal/docs/swagger.json b/pkg/docs/swagger.json similarity index 100% rename from internal/docs/swagger.json rename to pkg/docs/swagger.json diff --git a/internal/docs/swagger.yaml b/pkg/docs/swagger.yaml similarity index 100% rename from internal/docs/swagger.yaml rename to pkg/docs/swagger.yaml diff --git a/internal/errors/errors.go b/pkg/errors/errors.go similarity index 100% rename from internal/errors/errors.go rename to pkg/errors/errors.go diff --git a/internal/middleware/auth.go b/pkg/middleware/auth.go similarity index 100% rename from internal/middleware/auth.go rename to pkg/middleware/auth.go diff --git a/pkg/utils/http_utils.go b/pkg/utils/http_utils.go new file mode 100644 index 0000000..ce66daf --- /dev/null +++ b/pkg/utils/http_utils.go @@ -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) + } +} diff --git a/test/model_test.go b/test/model_test.go new file mode 100644 index 0000000..0380335 --- /dev/null +++ b/test/model_test.go @@ -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)) + } +} diff --git a/test/test_ips.json b/test/test_ips.json new file mode 100644 index 0000000..a2a2df7 --- /dev/null +++ b/test/test_ips.json @@ -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" } + } + } + ] +} \ No newline at end of file