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_URL=http://localhost:9083
|
||||||
KEYCLOAK_REALM=lacpass
|
KEYCLOAK_REALM=lacpass
|
||||||
KEYCLOAK_CLIENT_ID=app
|
KEYCLOAK_CLIENT_ID=app
|
||||||
|
KEYCLOAK_ADMIN_CLIENT_SECRET=admin-cli-keycloak-client-secret-key
|
||||||
KC_BOOTSTRAP_ADMIN_USERNAME=admin
|
KC_BOOTSTRAP_ADMIN_USERNAME=admin
|
||||||
KC_BOOTSTRAP_ADMIN_PASSWORD=admin
|
KC_BOOTSTRAP_ADMIN_PASSWORD=admin
|
||||||
KEYCLOAK_DEFAULT_USER=test
|
KEYCLOAK_DEFAULT_USER=test
|
||||||
KEYCLOAK_DEFAULT_USER_PASSWORD=test
|
KEYCLOAK_DEFAULT_USER_PASSWORD=test
|
||||||
API_SWAGGER=false
|
API_SWAGGER=false
|
||||||
POSTGRES_USER=postgres
|
POSTGRES_USER=keycloak
|
||||||
POSTGRES_PASSWORD=postgres
|
POSTGRES_PASSWORD=keycloak
|
||||||
KEYCLOAK_HOSTNAME=http://keycloak.lacpass.create.cl
|
KEYCLOAK_HOSTNAME=http://keycloak.lacpass.create.cl
|
||||||
FHIR_BASE_URL=http://lacpass.create.cl:8080
|
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
|
.env
|
||||||
.idea
|
.idea
|
||||||
|
|
||||||
temp/*
|
temp/*
|
||||||
tmp/*
|
tmp/*
|
||||||
*.exe
|
*.exe
|
||||||
mock_*
|
mock_*
|
||||||
content-generator
|
content-generator
|
||||||
*.tfvars
|
*.tfvars
|
||||||
.terraform
|
.terraform
|
||||||
.terraform.lock.hcl
|
.terraform.lock.hcl
|
||||||
templates
|
templates
|
||||||
out
|
out
|
||||||
debugger
|
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
|
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 PH4H API for handling business logic and
|
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
|
a Keycloak server for authentication and authorization. The entire stack is containerized using Docker and can be easily
|
||||||
managed with Docker Compose.
|
managed with Docker Compose.
|
||||||
|
|
||||||
@ -12,6 +12,7 @@ managed with Docker Compose.
|
|||||||
- [Prerequisites](#prerequisites)
|
- [Prerequisites](#prerequisites)
|
||||||
- [Getting Started](#getting-started)
|
- [Getting Started](#getting-started)
|
||||||
- [Configuration](#configuration)
|
- [Configuration](#configuration)
|
||||||
|
- [Environment Variables](/docs/environment.md)
|
||||||
- [Running the Application](#running-the-application)
|
- [Running the Application](#running-the-application)
|
||||||
- [Accessing the Services](#accessing-the-services)
|
- [Accessing the Services](#accessing-the-services)
|
||||||
- [Keycloak Admin Console](#keycloak-admin-console)
|
- [Keycloak Admin Console](#keycloak-admin-console)
|
||||||
@ -25,7 +26,7 @@ The architecture of this backend system is designed to separate concerns between
|
|||||||
|
|
||||||
## Components
|
## 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.
|
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.
|
- **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
|
### Configuration
|
||||||
|
|
||||||
- [Authentication](/docs/authentication.md)
|
- [Authentication](/docs/authentication.md)
|
||||||
- IPS PH4H API (WIP)
|
- IPS Lacpass API (WIP)
|
||||||
|
|
||||||
### ⚠️ Complete the not implemented calls
|
### ⚠️ 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.
|
After completing these steps, you can proceed to the next section on running the application.
|
||||||
|
|
||||||
|
|
||||||
### Running the Application
|
### Running the Application
|
||||||
|
|
||||||
Open a terminal in the root directory of the project and run the following command:
|
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:
|
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.
|
- Pull the official Docker images for Keycloak and Postgres.
|
||||||
- Create and start the containers for all three services.
|
- Create and start the containers for all three services.
|
||||||
- Attach your terminal to the logs of all running containers.
|
- 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.
|
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)
|
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
|
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
|
valid JWT from Keycloak to make successful requests. There is a [helper script](./scripts/auth.sh), where you can request
|
||||||
a token using:
|
a token using:
|
||||||
|
|||||||
@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
_ "ips-lacpass-backend/pkg/docs"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,25 +1,29 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"ips-lacpass-backend/internal/core"
|
customMiddleware "ips-lacpass-backend/pkg/middleware"
|
||||||
"ips-lacpass-backend/internal/repository/fhir"
|
|
||||||
"ips-lacpass-backend/internal/repository/keycloak"
|
|
||||||
"ips-lacpass-backend/internal/repository/vhl"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
_ "ips-lacpass-backend/internal/docs"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
"github.com/go-chi/httplog/v3"
|
"github.com/go-chi/httplog/v3"
|
||||||
httpSwagger "github.com/swaggo/http-swagger"
|
httpSwagger "github.com/swaggo/http-swagger"
|
||||||
|
|
||||||
"ips-lacpass-backend/internal/handler"
|
userClient "ips-lacpass-backend/internal/users/client"
|
||||||
customMiddleware "ips-lacpass-backend/internal/middleware"
|
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() {
|
func (a *App) loadRoutes() {
|
||||||
@ -90,7 +94,7 @@ func (a *App) loadRoutes() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) loadUserRoutesNoAuth(router chi.Router) {
|
func (a *App) loadUserRoutesNoAuth(router chi.Router) {
|
||||||
r := keycloak.NewKeycloakClient(
|
r := userClient.NewClient(
|
||||||
a.config.AuthInternalUrl,
|
a.config.AuthInternalUrl,
|
||||||
a.config.AuthRealm,
|
a.config.AuthRealm,
|
||||||
a.config.AuthAdminClientID,
|
a.config.AuthAdminClientID,
|
||||||
@ -99,13 +103,13 @@ func (a *App) loadUserRoutesNoAuth(router chi.Router) {
|
|||||||
a.config.AuthEmailClientID,
|
a.config.AuthEmailClientID,
|
||||||
a.config.AuthEmailLifespan,
|
a.config.AuthEmailLifespan,
|
||||||
)
|
)
|
||||||
s := core.NewUserService(r)
|
s := userCore.NewService(&r)
|
||||||
h := handler.NewUserHandler(s)
|
h := userHandler.NewHandler(&s)
|
||||||
router.Post("/", h.Create)
|
router.Post("/", h.Create)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) loadUserRoutesAuth(router chi.Router) {
|
func (a *App) loadUserRoutesAuth(router chi.Router) {
|
||||||
r := keycloak.NewKeycloakClient(
|
r := userClient.NewClient(
|
||||||
a.config.AuthInternalUrl,
|
a.config.AuthInternalUrl,
|
||||||
a.config.AuthRealm,
|
a.config.AuthRealm,
|
||||||
a.config.AuthAdminClientID,
|
a.config.AuthAdminClientID,
|
||||||
@ -114,27 +118,23 @@ func (a *App) loadUserRoutesAuth(router chi.Router) {
|
|||||||
a.config.AuthEmailClientID,
|
a.config.AuthEmailClientID,
|
||||||
a.config.AuthEmailLifespan,
|
a.config.AuthEmailLifespan,
|
||||||
)
|
)
|
||||||
s := core.NewUserService(r)
|
s := userCore.NewService(&r)
|
||||||
h := handler.NewUserHandler(s)
|
h := userHandler.NewHandler(&s)
|
||||||
router.Put("/update", h.Update)
|
router.Put("/update", h.Update)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) loadIpsRoute(router chi.Router) {
|
func (a *App) loadIpsRoute(router chi.Router) {
|
||||||
r := fhir.FhirRepository{
|
r := ipsClient.NewClient(a.config.FhirBaseUrl)
|
||||||
Client: &http.Client{},
|
s := ipsCore.NewService(&r)
|
||||||
BaseURL: a.config.FhirBaseUrl,
|
h := ipsHandler.NewHandler(&s)
|
||||||
}
|
|
||||||
s := core.NewFhirService(r)
|
|
||||||
h := handler.NewIpsHandler(s)
|
|
||||||
router.Get("/", h.Get)
|
router.Get("/", h.Get)
|
||||||
|
router.Post("/merge", h.Merge)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) loadVhlRoute(router chi.Router) {
|
func (a *App) loadVhlRoute(router chi.Router) {
|
||||||
r := vhl.VhlRepository{
|
r := vhlClient.NewClient(a.config.VhlBaseUrl)
|
||||||
Client: &http.Client{},
|
s := vhlCore.NewService(&r)
|
||||||
BaseURL: a.config.VhlBaseUrl,
|
h := vhlHandler.NewHandler(&s)
|
||||||
}
|
|
||||||
s := core.NewVhlService(r)
|
|
||||||
h := handler.NewVhlHandler(s)
|
|
||||||
router.Post("/", h.Create)
|
router.Post("/", h.Create)
|
||||||
|
router.Post("/fetch", h.Get)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,9 +13,10 @@ RUN go mod download
|
|||||||
|
|
||||||
COPY ./cmd/api/ ./
|
COPY ./cmd/api/ ./
|
||||||
COPY ./internal ./internal
|
COPY ./internal ./internal
|
||||||
|
COPY ./pkg ./pkg
|
||||||
|
|
||||||
RUN swag fmt
|
RUN swag fmt
|
||||||
RUN swag init -o internal/docs
|
RUN swag init -o pkg/docs
|
||||||
|
|
||||||
# Go stage to build the API
|
# Go stage to build the API
|
||||||
FROM golang:1.24 as builder
|
FROM golang:1.24 as builder
|
||||||
|
|||||||
@ -12,11 +12,11 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
API_PORT: ${API_PORT:-3000}
|
API_PORT: ${API_PORT:-3000}
|
||||||
AUTH_INTERNAL_URL: ${AUTH_INTERNAL_URL:-http://auth:8080}
|
AUTH_INTERNAL_URL: ${AUTH_INTERNAL_URL:-http://auth:8080}
|
||||||
AUTH_HOSTNAME: ${AUTH_URL:-http://localhost:9083}
|
AUTH_HOSTNAME: ${KEYCLOAK_URL:-http://localhost:9083}
|
||||||
AUTH_REALM: ${AUTH_REALM:-lacpass}
|
AUTH_REALM: ${KEYCLOAK_REALM:-lacpass}
|
||||||
AUTH_CLIENT_ID: ${AUTH_CLIENT_ID:-admin-cli}
|
AUTH_CLIENT_ID: ${AUTH_CLIENT_ID:-admin-cli}
|
||||||
# Need to set this after creating a client for Keycloak Admin API access, using service account
|
# 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_REDIRECT_URI: ${AUTH_EMAIL_REDIRECT_URI:-ph4happ://open/validated-email}
|
||||||
AUTH_EMAIL_CLIENT_ID: ${AUTH_EMAIL_CLIENT_ID:-app}
|
AUTH_EMAIL_CLIENT_ID: ${AUTH_EMAIL_CLIENT_ID:-app}
|
||||||
FHIR_BASE_URL: ${FHIR_BASE_URL:-http://lacpass.create.cl:8080}
|
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
|
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:
|
Then, to start keycloak we can run it from the root directory with docker compose as:
|
||||||
|
|
||||||
```bash
|
```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/chi/v5 v5.2.1
|
||||||
github.com/go-chi/httplog/v3 v3.2.2
|
github.com/go-chi/httplog/v3 v3.2.2
|
||||||
github.com/go-playground/validator/v10 v10.26.0
|
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/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/http-swagger v1.3.4
|
||||||
github.com/swaggo/swag v1.16.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/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 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
|
||||||
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
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 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
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/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 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
|
||||||
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
|
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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
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=
|
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 {
|
type Bundle struct {
|
||||||
ResourceType string `json:"resourceType"`
|
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 (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
@ -6,16 +6,23 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"ips-lacpass-backend/internal/core"
|
"ips-lacpass-backend/pkg/errors"
|
||||||
"ips-lacpass-backend/internal/errors"
|
"ips-lacpass-backend/pkg/utils"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewKeycloakClient(baseURL, realm, clientId, clientSecret, emailRedirectUri, emailClientId string, emailLifeSpan int) core.UserRepository {
|
type ClientInterface interface {
|
||||||
return &Client{
|
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{},
|
Client: &http.Client{},
|
||||||
BaseURL: baseURL,
|
BaseURL: baseURL,
|
||||||
Realm: realm,
|
Realm: realm,
|
||||||
@ -24,10 +31,11 @@ func NewKeycloakClient(baseURL, realm, clientId, clientSecret, emailRedirectUri,
|
|||||||
EmailRedirectURI: emailRedirectUri,
|
EmailRedirectURI: emailRedirectUri,
|
||||||
EmailClientID: emailClientId,
|
EmailClientID: emailClientId,
|
||||||
EmailLifespan: emailLifeSpan,
|
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)
|
tokenEndpoint := fmt.Sprintf("%s/realms/%s/protocol/openid-connect/token", kc.BaseURL, kc.Realm)
|
||||||
|
|
||||||
data := url.Values{}
|
data := url.Values{}
|
||||||
@ -37,14 +45,13 @@ func (kc *Client) getAccessToken() (*TokenResponse, error) {
|
|||||||
|
|
||||||
resp, err := http.PostForm(tokenEndpoint, data)
|
resp, err := http.PostForm(tokenEndpoint, data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to connect to Keycloak: %w", err)
|
return nil, &errors.HttpError{
|
||||||
}
|
StatusCode: 502,
|
||||||
defer func(Body io.ReadCloser) {
|
Body: []map[string]interface{}{{"error": "service_unavailable", "message": "Failed to connect to Keycloak service"}},
|
||||||
err := Body.Close()
|
Err: err,
|
||||||
if err != nil {
|
|
||||||
fmt.Errorf("failed to close response body: %s", err.Error())
|
|
||||||
}
|
}
|
||||||
}(resp.Body)
|
}
|
||||||
|
defer utils.CloseBody(resp.Body)
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
body, _ := io.ReadAll(resp.Body)
|
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 {
|
if err := json.NewDecoder(resp.Body).Decode(&token); err != nil {
|
||||||
return nil, fmt.Errorf("failed to decode token response: %w", err)
|
return nil, fmt.Errorf("failed to decode token response: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &token, nil
|
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"}
|
actions := []string{"VERIFY_EMAIL"}
|
||||||
body, err := json.Marshal(actions)
|
body, err := json.Marshal(actions)
|
||||||
if err != nil {
|
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("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)
|
resp, err := kc.Client.Do(req)
|
||||||
if err != nil {
|
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 {
|
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
|
||||||
var errorResponse map[string]interface{}
|
var errorResponse map[string]interface{}
|
||||||
@ -108,17 +143,17 @@ func (kc *Client) sendValidationEmail(ctx context.Context, userID string) error
|
|||||||
return nil
|
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{
|
r := UserRegistrationRequest{
|
||||||
Username: user.Identifier,
|
Username: user["Identifier"].(string),
|
||||||
Email: user.Email,
|
Email: user["Email"].(string),
|
||||||
FirstName: user.FirstName,
|
FirstName: user["FirstName"].(string),
|
||||||
LastName: user.LastName,
|
LastName: user["LastName"].(string),
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
Attributes: map[string][]string{
|
Attributes: map[string][]string{
|
||||||
"locale": {user.Locale},
|
"locale": {user["Locale"].(string)},
|
||||||
"document_type": {string(user.DocumentType)},
|
"document_type": {user["DocumentType"].(string)},
|
||||||
"identifier": {user.Identifier},
|
"identifier": {user["Identifier"].(string)},
|
||||||
},
|
},
|
||||||
Credentials: []UserCredential{
|
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)
|
return nil, fmt.Errorf("failed to get access token: %w", err)
|
||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", "application/json")
|
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)
|
resp, err := kc.Client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to send request: %w", err)
|
return nil, &errors.HttpError{
|
||||||
}
|
StatusCode: 502,
|
||||||
defer func(Body io.ReadCloser) {
|
Body: []map[string]interface{}{{"error": "service_unavailable", "message": "Failed to connect to Auth service"}},
|
||||||
err := Body.Close()
|
Err: err,
|
||||||
if err != nil {
|
|
||||||
fmt.Println("failed to close response body: %v", err)
|
|
||||||
}
|
}
|
||||||
}(resp.Body)
|
}
|
||||||
|
defer utils.CloseBody(resp.Body)
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusCreated {
|
if resp.StatusCode != http.StatusCreated {
|
||||||
var errorResponse map[string]interface{}
|
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, "/")
|
parts := strings.Split(location, "/")
|
||||||
userID := parts[len(parts)-1]
|
userID := parts[len(parts)-1]
|
||||||
fmt.Println("New user ID:", userID)
|
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
newCtx := context.Background()
|
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
|
// 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 the first user since we're querying by username/email
|
||||||
return &core.User{
|
return &UserID{ID: userID}, nil
|
||||||
ID: userID,
|
|
||||||
Username: user.Identifier,
|
|
||||||
Email: user.Email,
|
|
||||||
FirstName: user.FirstName,
|
|
||||||
LastName: user.LastName,
|
|
||||||
Locale: user.Locale,
|
|
||||||
DocumentType: user.DocumentType,
|
|
||||||
Identifier: user.Identifier,
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (kc *Client) UpdateUser(ctx context.Context, userUUID string, ur core.UserUpdateRequest) (*core.User, error) {
|
func (kc *UserClient) UpdateUser(ctx context.Context, userUUID string, ur map[string]interface{}) (*UserRegistration, error) {
|
||||||
// Get UserRepresentation from keycloack
|
// Get UserRepresentation from Keycloak
|
||||||
ku := fmt.Sprintf("%s/admin/realms/%s/users/%s", kc.BaseURL, kc.Realm, userUUID)
|
ku := fmt.Sprintf("%s/admin/realms/%s/users/%s", kc.BaseURL, kc.Realm, userUUID)
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, ku, nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, ku, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -208,17 +231,21 @@ func (kc *Client) UpdateUser(ctx context.Context, userUUID string, ur core.UserU
|
|||||||
}
|
}
|
||||||
t, err := kc.getAccessToken()
|
t, err := kc.getAccessToken()
|
||||||
if err != nil {
|
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("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)
|
resp, err := kc.Client.Do(req)
|
||||||
defer func(Body io.ReadCloser) {
|
if err != nil {
|
||||||
err := Body.Close()
|
return nil, &errors.HttpError{
|
||||||
if err != nil {
|
StatusCode: 502,
|
||||||
fmt.Println("failed to close response body: %w", err)
|
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 {
|
if resp.StatusCode != http.StatusOK {
|
||||||
var errorResponse map[string]interface{}
|
var errorResponse map[string]interface{}
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&errorResponse); err != nil {
|
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
|
// Edit User representation
|
||||||
// For now, only firstName and lastName can be edited
|
// For now, only firstName and lastName can be edited
|
||||||
changed := false
|
changed := false
|
||||||
if ur.FirstName != userRep.FirstName {
|
if ur["first_name"].(string) != userRep.FirstName {
|
||||||
userRep.FirstName = ur.FirstName
|
userRep.FirstName = ur["first_name"].(string)
|
||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
if ur.LastName != userRep.LastName {
|
if ur["last_name"].(string) != userRep.LastName {
|
||||||
userRep.LastName = ur.LastName
|
userRep.LastName = ur["last_name"].(string)
|
||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
if !changed {
|
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)
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", "application/json")
|
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)
|
resp, err = kc.Client.Do(req)
|
||||||
defer func(Body io.ReadCloser) {
|
if err != nil {
|
||||||
err := Body.Close()
|
return nil, &errors.HttpError{
|
||||||
if err != nil {
|
StatusCode: 502,
|
||||||
fmt.Println("failed to close response body: %w", err)
|
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 {
|
if resp.StatusCode != http.StatusNoContent {
|
||||||
var errorResponse map[string]interface{}
|
var errorResponse map[string]interface{}
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&errorResponse); err != nil {
|
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,
|
Err: err,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return &core.User{
|
return &userRep, nil
|
||||||
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
|
|
||||||
}
|
}
|
||||||
@ -1,10 +1,12 @@
|
|||||||
package keycloak
|
package client
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Client struct {
|
type UserClient struct {
|
||||||
Client *http.Client
|
Client *http.Client
|
||||||
BaseURL string
|
BaseURL string
|
||||||
Realm string
|
Realm string
|
||||||
@ -13,6 +15,13 @@ type Client struct {
|
|||||||
EmailRedirectURI string
|
EmailRedirectURI string
|
||||||
EmailLifespan int
|
EmailLifespan int
|
||||||
EmailClientID string
|
EmailClientID string
|
||||||
|
TokenManager *TokenManager
|
||||||
|
}
|
||||||
|
|
||||||
|
type TokenManager struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
token string
|
||||||
|
tokenExpiresAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserRegistration struct {
|
type UserRegistration struct {
|
||||||
@ -25,6 +34,10 @@ type UserRegistration struct {
|
|||||||
Credentials []Credential `json:"credentials,omitempty"`
|
Credentials []Credential `json:"credentials,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type UserID struct {
|
||||||
|
ID string
|
||||||
|
}
|
||||||
|
|
||||||
type Credential struct {
|
type Credential struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Value string `json:"value,omitempty"`
|
Value string `json:"value,omitempty"`
|
||||||
@ -33,11 +46,9 @@ type Credential struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type TokenResponse struct {
|
type TokenResponse struct {
|
||||||
AccessToken string `json:"access_token"`
|
AccessToken string `json:"access_token"`
|
||||||
RefreshToken string `json:"refresh_token"`
|
ExpiresIn int `json:"expires_in"`
|
||||||
ExpiresIn int `json:"expires_in"`
|
TokenType string `json:"token_type"`
|
||||||
RefreshExpiresIn int `json:"refresh_expires_in"`
|
|
||||||
TokenType string `json:"token_type"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserRegistrationRequest struct {
|
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"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"ips-lacpass-backend/internal/core"
|
"ips-lacpass-backend/internal/users/core"
|
||||||
errors2 "ips-lacpass-backend/internal/errors"
|
errors2 "ips-lacpass-backend/pkg/errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
@ -13,12 +13,12 @@ import (
|
|||||||
"github.com/go-playground/validator/v10"
|
"github.com/go-playground/validator/v10"
|
||||||
)
|
)
|
||||||
|
|
||||||
type UserHandler struct {
|
type Handler struct {
|
||||||
UserService core.UserService
|
Service core.ServiceInterface
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewUserHandler(s core.UserService) *UserHandler {
|
func NewHandler(s core.ServiceInterface) *Handler {
|
||||||
return &UserHandler{UserService: s}
|
return &Handler{Service: s}
|
||||||
}
|
}
|
||||||
|
|
||||||
var matchFirstCap = regexp.MustCompile("(.)([A-Z][a-z]+)")
|
var matchFirstCap = regexp.MustCompile("(.)([A-Z][a-z]+)")
|
||||||
@ -81,7 +81,7 @@ type userUpdateRequest struct {
|
|||||||
// @Failure 404
|
// @Failure 404
|
||||||
// @Failure 500
|
// @Failure 500
|
||||||
// @Router /users [post]
|
// @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
|
var body userCreationRequest
|
||||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
@ -138,7 +138,7 @@ func (u *UserHandler) Create(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.Write(res)
|
w.Write(res)
|
||||||
}
|
}
|
||||||
|
|
||||||
user, err := u.UserService.CreateUser(r.Context(), core.UserRequest{
|
user, err := u.Service.CreateUser(r.Context(), core.UserRequest{
|
||||||
Email: body.Email,
|
Email: body.Email,
|
||||||
Password: body.Password,
|
Password: body.Password,
|
||||||
PasswordConfirm: body.PasswordConfirm,
|
PasswordConfirm: body.PasswordConfirm,
|
||||||
@ -204,7 +204,7 @@ func (u *UserHandler) Create(w http.ResponseWriter, r *http.Request) {
|
|||||||
// @Failure 404
|
// @Failure 404
|
||||||
// @Failure 500
|
// @Failure 500
|
||||||
// @Router /users/auth/update [put]
|
// @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")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|
||||||
var body userUpdateRequest
|
var body userUpdateRequest
|
||||||
@ -237,7 +237,7 @@ func (u *UserHandler) Update(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
user, err := u.UserService.UpdateUser(r.Context(), core.UserUpdateRequest{
|
user, err := u.Service.UpdateUser(r.Context(), core.UserUpdateRequest{
|
||||||
FirstName: body.FirstName,
|
FirstName: body.FirstName,
|
||||||
LastName: body.LastName,
|
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