diff --git a/.env.example b/.env.example index fea12b6d..7a392688 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,6 @@ AGENT_CONFIG_LOG_FORMAT=json AGENT_CONFIG_EVENT_STORE=postgres -AGENT_APPLICATION_HOST=my-domain.example.org +AGENT_APPLICATION_URL=https://my-domain.example.org AGENT_ISSUANCE_CREDENTIAL_NAME="Demo Credential" AGENT_ISSUANCE_CREDENTIAL_LOGO_URL=https://my-domain.example.org/credential_logo.png AGENT_STORE_DB_CONNECTION_STRING=postgresql://demo_user:demo_pass@localhost:5432/demo diff --git a/.github/workflows/push.yaml b/.github/workflows/push.yaml new file mode 100644 index 00000000..c2662ddb --- /dev/null +++ b/.github/workflows/push.yaml @@ -0,0 +1,67 @@ +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +# GitHub recommends pinning actions to a commit SHA. +# To get a newer version, you will need to update the SHA. +# You can also reference a tag or branch, but the action may change without warning. + +name: Build and Deploy to GKE + +on: + workflow_dispatch: + +env: + IMAGE: unicore + +jobs: + setup-build-publish-deploy: + name: Setup, Build, Publish, and Deploy + runs-on: ubuntu-latest + environment: dev + env: + PROJECT_ID: ${{ secrets.PROJECT_ID }} + + permissions: + contents: "read" + id-token: "write" + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: "Auth" + uses: "google-github-actions/auth@v2" + with: + token_format: "access_token" + workload_identity_provider: projects/${{ secrets.PROJECT_NR }}/locations/global/workloadIdentityPools/workload-ip/providers/workload-ip-provider + service_account: k8s-user@${{ secrets.PROJECT_ID }}.iam.gserviceaccount.com + + - name: "Set up Cloud SDK" + uses: "google-github-actions/setup-gcloud@v2" + + - name: "Use gcloud CLI" + run: "gcloud info" + + - name: Build + working-directory: ".pipeline" + run: chmod u+x ./build.sh && ./build.sh + + # Get the GKE credentials so we can deploy to the cluster + - uses: google-github-actions/get-gke-credentials@v2 + with: + cluster_name: ${{ vars.GKE_CLUSTER_NAME }} + project_id: ${{ secrets.PROJECT_ID }} + location: ${{ vars.GKE_COMPUTE_ZONE }} + + - name: Create secret + run: | + kubectl -n ingress-apisix delete secret unicore-db-secret --ignore-not-found + kubectl -n ingress-apisix create secret generic unicore-db-secret \ + --from-literal='connection-string=${{ secrets.AGENT_STORE_DB_CONNECTION_STRING }}' + + ## Deploy the Docker image to the GKE cluster + - name: Deploy + working-directory: ".pipeline" + run: chmod u+x ./deploy.sh && ./deploy.sh diff --git a/.pipeline/.gitignore b/.pipeline/.gitignore new file mode 100644 index 00000000..567609b1 --- /dev/null +++ b/.pipeline/.gitignore @@ -0,0 +1 @@ +build/ diff --git a/.pipeline/README.md b/.pipeline/README.md new file mode 100644 index 00000000..1cc5d793 --- /dev/null +++ b/.pipeline/README.md @@ -0,0 +1,14 @@ +# Pipeline + +In order to run the pipeline build script locally, create a `.env` file in `.github/.pipeline` and add the following content: + +```sh +IMAGE=unicore +ARTIFACT_REGISTRY_HOST= +ARTIFACT_REGISTRY_REPOSITORY= +PROJECT_ID= +GITHUB_SHA=test_sha +APISIX_PATH=unicore +``` + +Then execute `./build.sh`. diff --git a/.pipeline/build.sh b/.pipeline/build.sh new file mode 100755 index 00000000..f48f54b8 --- /dev/null +++ b/.pipeline/build.sh @@ -0,0 +1,47 @@ +#!/bin/bash + +set -e + +[ -z "$IMAGE" ] && echo "Need to set IMAGE" && exit 1; +[ -z "$ARTIFACT_REGISTRY_HOST" ] && echo "Need to set ARTIFACT_REGISTRY_HOST" && exit 1; +[ -z "$ARTIFACT_REGISTRY_REPOSITORY" ] && echo "Need to set ARTIFACT_REGISTRY_REPOSITORY" && exit 1; +[ -z "$PROJECT_ID" ] && echo "Need to set PROJECT_ID" && exit 1; +[ -z "$GITHUB_SHA" ] && echo "Need to set GITHUB_SHA" && exit 1; + +export CONTAINER_REPO="$ARTIFACT_REGISTRY_HOST/$PROJECT_ID/$ARTIFACT_REGISTRY_REPOSITORY" + +echo $CONTAINER_REPO + +# Configure Docker to use the gcloud command-line tool as a credential +# helper for authentication +gcloud auth configure-docker $ARTIFACT_REGISTRY_HOST + +[ -e build/ ] && rm -rf build + +echo "-------------------------------------------------------------" +echo "Create build directory" +echo "-------------------------------------------------------------" + +mkdir build && cp *.yaml build && cd build + +echo "-------------------------------------------------------------" +echo "Replace environment variables in files" +echo "-------------------------------------------------------------" + +sed -i -e 's|@IMAGE@|'"$IMAGE"'|g' *.yaml +sed -i -e 's|@CONTAINER_REPO@|'"$CONTAINER_REPO/$IMAGE:$GITHUB_SHA"'|g' *.yaml + +echo "-------------------------------------------------------------" +echo "Display yaml files" +echo "-------------------------------------------------------------" + +for f in *.yaml; do printf "\n---\n"; cat "${f}"; done + +cd ../../agent_application + +echo "-------------------------------------------------------------" +echo "Build and push docker container" +echo "-------------------------------------------------------------" + +docker build -t "$CONTAINER_REPO/$IMAGE:$GITHUB_SHA" -f docker/Dockerfile .. +docker push "$CONTAINER_REPO/$IMAGE:$GITHUB_SHA" diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..d579de07 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,4 @@ +### 24-01-2024 + +Environment variable `AGENT_APPLICATION_HOST` has changed to `AGENT_APPLICATION_URL` and requires the complete URL. e.g.: +`https://my.domain.com/unicore`. In case you don't have rewrite root enabled on your reverse proxy, you will have to set `AGENT_CONFIG_BASE_PATH` as well. e.g.: `unicore`. diff --git a/Cargo.lock b/Cargo.lock index 988fe6e8..07ca2a64 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -93,6 +93,7 @@ dependencies = [ "config", "dotenvy", "tracing", + "url", ] [[package]] diff --git a/README.md b/README.md index 4b2a8f2a..c18efb70 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,10 @@ Build and run the **SSI Agent** in a local Docker environment following [these steps](./agent_application/docker/README.md). +## Breaking changes + +From time to time breaking changes can occur. Please make sure you read the [CHANGELOG](./CHANGELOG.md) before updating. + ## Architecture ![alt text](UniCore.drawio.png "UniCore") @@ -16,22 +20,27 @@ UniCore makes use of several practical architectural principles—specifically, Sourcing. Together, these principles contribute to a robust and scalable software solution. ### Hexagonal Architecture + Hexagonal Architecture promotes modularity by separating the core business logic from external dependencies. UniCore's core functionality remains untangled from external frameworks, making it adaptable to changes without affecting the overall system. #### Core + The core business logic of UniCore currently consists of the [**Core Issuance Agent**](./agent_issuance/README.md). This component is responsible for handling the issuance of credentials and offers. It defines the rules by which incoming **Commands** can change the state by emitting **Events**. The Core Issuance Agent has two major functions: + - **Preparations**: Preparing the data that will be used in the issuance of credentials and credential offers. - **Credential Issuance**: Issuing credentials according to the OpenID for Verifiable Credential Issuance specification. #### Adapters + UniCore's adapters are responsible for handling the communication between the core and external systems. Adapters can either be **Inbound** or **Outbound**. Inbound adapters are responsible for receiving incoming requests and translating them into commands that can be understood by the core. Outbound adapters are responsible for translating the core's **Events** into outgoing requests. In our current implementation, we have the following adapters: + - [**REST API**](./agent_api_rest/) (Inbound): The REST API is responsible for receiving incoming HTTP requests from clients and translating them into commands that can be understood by the core. - [**Event Store**](./agent_store/) (Outbound): The Event Store is responsible for storing the events emitted by the @@ -39,12 +48,15 @@ them into commands that can be understood by the core. Outbound adapters are res in-memory database for testing purposes. #### Application + The [**Application**](./agent_application/) is responsible for orchestrating the core and adapters. It is responsible for initializing the core and adapters and connecting them together. ### CQRS + CQRS is a design pattern that separates the responsibility for handling commands (changing state) from handling queries (retrieving state). + - **Commands**: Commands are actions that are responsible for executing business logic and updating the application state. - **Queries**: Queries are responsible for reading data without modifying the state. @@ -53,10 +65,11 @@ The separation of commands and queries simplifies the design and maintenance of optimization of each side independently. ### Event Sourcing -Event Sourcing is a pattern in which the application's state is determined by a sequence of events. Each event signifies a state change and is preserved in an event store. These **Events** serve as immutable facts about alterations in the application's state. The **Event Store**, functioning as a database, records events in the order of their occurrence. Consequently, it enables the reconstruction of the application's state at any given moment. This pattern not only ensures a dependable audit log for monitoring changes but also facilitates querying the system's state at various intervals. +Event Sourcing is a pattern in which the application's state is determined by a sequence of events. Each event signifies a state change and is preserved in an event store. These **Events** serve as immutable facts about alterations in the application's state. The **Event Store**, functioning as a database, records events in the order of their occurrence. Consequently, it enables the reconstruction of the application's state at any given moment. This pattern not only ensures a dependable audit log for monitoring changes but also facilitates querying the system's state at various intervals. ## Interaction Sequence + This sequence diagram illustrates the dynamic interaction flow within UniCore, focusing on the preparation and issuance of credentials and offers. The diagram also illustrates the OpenID4VCI Pre-Authorized Code Flow, which is used by wallets to obtain access tokens and credentials. ```mermaid @@ -72,7 +85,7 @@ sequenceDiagram autonumber - Note over api_rest, store: Command and Query
Responsibility Segregation (CQRS) + Note over api_rest, store: Command and Query
Responsibility Segregation (CQRS) Note over client, store: Agent Preparations @@ -95,7 +108,7 @@ sequenceDiagram wallet->>api_rest: GET /.well-known/oauth-authorization-server api_rest->>store: Query store->>api_rest: View - api_rest->>wallet: 200 OK application/json + api_rest->>wallet: 200 OK application/json wallet->>api_rest: GET /.well-known/openid-credential-issuer api_rest->>store: Query @@ -143,4 +156,4 @@ OpenID4VCI Pre-Authorized Code Flow 28-29: See steps 2-3. 30-31: See steps 4-5. 32: The API returns a `200 OK` response with the credential(s) in the response body. -``` \ No newline at end of file +``` diff --git a/agent_api_rest/Cargo.toml b/agent_api_rest/Cargo.toml index d85c6236..4752a1aa 100644 --- a/agent_api_rest/Cargo.toml +++ b/agent_api_rest/Cargo.toml @@ -5,10 +5,10 @@ edition = "2021" [dependencies] agent_issuance = { path = "../agent_issuance" } +agent_shared = {path = "../agent_shared"} oid4vci = { git = "https://git@github.com/impierce/openid4vc.git", branch = "feat/refactor-request" } oid4vc-core = { git = "https://git@github.com/impierce/openid4vc.git", branch = "feat/refactor-request" } - axum = "0.6" axum-auth = "0.4" axum-macros = "0.3" diff --git a/agent_api_rest/src/credential_issuer/credential.rs b/agent_api_rest/src/credential_issuer/credential.rs index cd6cdade..1bed215e 100644 --- a/agent_api_rest/src/credential_issuer/credential.rs +++ b/agent_api_rest/src/credential_issuer/credential.rs @@ -92,7 +92,7 @@ mod tests { .oneshot( Request::builder() .method(http::Method::POST) - .uri(format!("/openid4vci/credential")) + .uri("/openid4vci/credential") .header(http::header::CONTENT_TYPE, mime::APPLICATION_JSON.as_ref()) .header(http::header::AUTHORIZATION, format!("Bearer {}", access_token)) .body(Body::from( diff --git a/agent_api_rest/src/credential_issuer/token.rs b/agent_api_rest/src/credential_issuer/token.rs index fc335487..56221fbc 100644 --- a/agent_api_rest/src/credential_issuer/token.rs +++ b/agent_api_rest/src/credential_issuer/token.rs @@ -93,7 +93,7 @@ mod tests { .oneshot( Request::builder() .method(http::Method::POST) - .uri(format!("/auth/token")) + .uri("/auth/token") .header( http::header::CONTENT_TYPE, mime::APPLICATION_WWW_FORM_URLENCODED.as_ref(), diff --git a/agent_api_rest/src/credential_issuer/well_known/openid_credential_issuer.rs b/agent_api_rest/src/credential_issuer/well_known/openid_credential_issuer.rs index fdbffb91..89f68010 100644 --- a/agent_api_rest/src/credential_issuer/well_known/openid_credential_issuer.rs +++ b/agent_api_rest/src/credential_issuer/well_known/openid_credential_issuer.rs @@ -35,7 +35,7 @@ mod tests { startup_commands::{create_credentials_supported, load_credential_issuer_metadata}, state::{initialize, CQRS}, }; - use agent_shared::config; + use agent_shared::{config, UrlAppendHelpers}; use agent_store::in_memory; use axum::{ body::Body, @@ -85,12 +85,13 @@ mod tests { let body = hyper::body::to_bytes(response.into_body()).await.unwrap(); let credential_issuer_metadata: CredentialIssuerMetadata = serde_json::from_slice(&body).unwrap(); + assert_eq!( credential_issuer_metadata, CredentialIssuerMetadata { credential_issuer: BASE_URL.clone(), authorization_server: None, - credential_endpoint: BASE_URL.join("openid4vci/credential").unwrap(), + credential_endpoint: BASE_URL.append_path_segment("openid4vci/credential"), batch_credential_endpoint: None, deferred_credential_endpoint: None, credentials_supported: vec![CredentialsSupportedObject { diff --git a/agent_api_rest/src/lib.rs b/agent_api_rest/src/lib.rs index a5df3de2..0d768923 100644 --- a/agent_api_rest/src/lib.rs +++ b/agent_api_rest/src/lib.rs @@ -3,6 +3,7 @@ mod credentials; mod offers; use agent_issuance::{model::aggregate::IssuanceData, queries::IssuanceDataView, state::ApplicationState}; +use agent_shared::{config, ConfigError}; use axum::{ routing::{get, post}, Router, @@ -21,31 +22,80 @@ use offers::offers; pub const AGGREGATE_ID: &str = "agg-id-F39A0C"; pub fn app(state: ApplicationState) -> Router { + let base_path = get_base_path(); + + let path = |suffix: &str| -> String { + if let Ok(base_path) = &base_path { + format!("/{}{}", base_path, suffix) + } else { + suffix.to_string() + } + }; + Router::new() - .route("/v1/credentials", post(credentials)) - .route("/v1/offers", post(offers)) + .route(&path("/v1/credentials"), post(credentials)) + .route(&path("/v1/offers"), post(offers)) .route( - "/.well-known/oauth-authorization-server", + &path("/.well-known/oauth-authorization-server"), get(oauth_authorization_server), ) - .route("/.well-known/openid-credential-issuer", get(openid_credential_issuer)) - .route("/auth/token", post(token)) - .route("/openid4vci/credential", post(credential)) + .route( + &path("/.well-known/openid-credential-issuer"), + get(openid_credential_issuer), + ) + .route(&path("/auth/token"), post(token)) + .route(&path("/openid4vci/credential"), post(credential)) .with_state(state) } +fn get_base_path() -> Result { + config!("base_path").map(|mut base_path| { + if base_path.starts_with('/') { + base_path.remove(0); + } + + if base_path.ends_with('/') { + base_path.pop(); + } + + if base_path.is_empty() { + panic!("AGENT_APPLICATION_BASE_PATH can't be empty, remove or set path"); + } + + tracing::info!("Base path: {:?}", base_path); + + base_path + }) +} + #[cfg(test)] mod tests { use super::*; - use agent_issuance::command::IssuanceCommand; + use agent_issuance::state::CQRS; + use agent_issuance::{command::IssuanceCommand, services::IssuanceServices}; + use agent_store::in_memory; use serde_json::json; pub const PRE_AUTHORIZED_CODE: &str = "pre-authorized_code"; pub const SUBJECT_ID: &str = "00000000-0000-0000-0000-000000000000"; + lazy_static::lazy_static! { pub static ref BASE_URL: url::Url = url::Url::parse("https://example.com").unwrap(); } + async fn handler() {} + + #[tokio::test] + #[should_panic] + async fn test_base_path_routes() { + let state = in_memory::ApplicationState::new(vec![], IssuanceServices {}).await; + + std::env::set_var("AGENT_APPLICATION_BASE_PATH", "unicore"); + let router = app(state); + + let _ = router.route("/auth/token", post(handler)); + } + pub async fn create_unsigned_credential(state: ApplicationState) -> String { state .execute_with_metadata( diff --git a/agent_application/docker/README.md b/agent_application/docker/README.md index f5044c39..fe6a6a5d 100644 --- a/agent_application/docker/README.md +++ b/agent_application/docker/README.md @@ -12,14 +12,17 @@ docker build -f docker/Dockerfile -t ssi-agent .. Inside the folder `/agent_application/docker`: -1. _Inside `docker-compose.yml` replace the value `` for the environment variable `AGENT_APPLICATION_HOST` with your actual local ip address (such as 192.168.1.234)_ +1. Inside `docker-compose.yml` replace the environment value: `AGENT_APPLICATION_URL` with your actual local ip address or url (such as http://192.168.1.234:3033) 2. Optionally, add the following environment variables: - - `AGENT_ISSUANCE_CREDENTIAL_NAME`: To set the name of the credentials that will be issued. - - `AGENT_ISSUANCE_CREDENTIAL_LOGO_URL`: To set the URL of the logo that will be used in the credentials. + - `AGENT_ISSUANCE_CREDENTIAL_NAME`: To set the name of the credentials that will be issued. + - `AGENT_ISSUANCE_CREDENTIAL_LOGO_URL`: To set the URL of the logo that will be used in the credentials. 3. To start the **SSI Agent**, a **Postgres** database along with **pgadmin** (Postgres Admin Interface) simply run: ```bash -docker compose up -d +docker compose up ``` -3. The REST API will be served at `http://0.0.0.0:3033` +4. The REST API will be served at `http://0.0.0.0:3033` + +> [!NOTE] +> If you don't have rewrite rules enabled on your reverse proxy, you can set the `AGENT_CONFIG_BASE_PATH` to a value such as `ssi-agent`. diff --git a/agent_application/docker/docker-compose.yml b/agent_application/docker/docker-compose.yml index a5b106ab..117dd9f5 100644 --- a/agent_application/docker/docker-compose.yml +++ b/agent_application/docker/docker-compose.yml @@ -27,12 +27,15 @@ services: depends_on: - cqrs-postgres-db ssi-agent: - image: ssi-agent - restart: always + #image: ssi-agent + build: + context: ../.. + dockerfile: ./agent_application/docker/Dockerfile ports: - 3033:3033 environment: - AGENT_CONFIG_LOG_FORMAT: json + #AGENT_CONFIG_LOG_FORMAT: json AGENT_CONFIG_EVENT_STORE: postgres - AGENT_APPLICATION_HOST: ${AGENT_APPLICATION_HOST:?set it please} + #AGENT_CONFIG_BASE_PATH: "unicore" + AGENT_APPLICATION_URL: ${AGENT_APPLICATION_URL} AGENT_STORE_DB_CONNECTION_STRING: postgresql://demo_user:demo_pass@cqrs-postgres-db:5432/demo diff --git a/agent_application/src/main.rs b/agent_application/src/main.rs index 66b63a60..7624c6eb 100644 --- a/agent_application/src/main.rs +++ b/agent_application/src/main.rs @@ -7,11 +7,6 @@ use agent_issuance::{ }; use agent_shared::config; use agent_store::{in_memory, postgres}; -use lazy_static::lazy_static; - -lazy_static! { - static ref HOST: url::Url = format!("http://{}:3033/", config!("host").unwrap()).parse().unwrap(); -} #[tokio::main] async fn main() { @@ -20,14 +15,27 @@ async fn main() { _ => in_memory::ApplicationState::new(vec![Box::new(SimpleLoggingQuery {})], IssuanceServices {}).await, }; - match config!("log_format").unwrap().as_str() { - "json" => tracing_subscriber::fmt().json().init(), - _ => tracing_subscriber::fmt::init(), + if let Ok(log_format) = config!("log_format") { + if &log_format == "json" { + tracing_subscriber::fmt().json().init() + } else { + tracing_subscriber::fmt::init() + } + } else { + tracing_subscriber::fmt::init() } - initialize(state.clone(), startup_commands(HOST.clone())).await; + let url = config!("url").expect("AGENT_APPLICATION_URL is not set"); + + tracing::info!("Application url: {:?}", url); + + let url = url::Url::parse(&url).unwrap(); + + initialize(state.clone(), startup_commands(url)).await; + + let server = "0.0.0.0:3033".parse().unwrap(); - axum::Server::bind(&"0.0.0.0:3033".parse().unwrap()) + axum::Server::bind(&server) .serve(app(state).into_make_service()) .await .unwrap(); diff --git a/agent_issuance/src/model/aggregate.rs b/agent_issuance/src/model/aggregate.rs index a2649c53..a025197a 100644 --- a/agent_issuance/src/model/aggregate.rs +++ b/agent_issuance/src/model/aggregate.rs @@ -422,6 +422,7 @@ pub mod tests { }; use super::*; + use agent_shared::UrlAppendHelpers; use cqrs_es::test::TestFramework; use did_key::Ed25519KeyPair; use lazy_static::lazy_static; @@ -646,15 +647,15 @@ pub mod tests { static ref AUTHORIZATION_SERVER_METADATA: Box = Box::new(AuthorizationServerMetadata { issuer: BASE_URL.clone(), - token_endpoint: Some(BASE_URL.join("token").unwrap()), + token_endpoint: Some(BASE_URL.append_path_segment("token")), ..Default::default() }); static ref CREDENTIAL_ISSUER_METADATA: CredentialIssuerMetadata = CredentialIssuerMetadata { credential_issuer: BASE_URL.clone(), authorization_server: None, - credential_endpoint: BASE_URL.join("credential").unwrap(), + credential_endpoint: BASE_URL.append_path_segment("credential"), deferred_credential_endpoint: None, - batch_credential_endpoint: Some(BASE_URL.join("batch_credential").unwrap()), + batch_credential_endpoint: Some(BASE_URL.append_path_segment("batch_credential")), credentials_supported: vec![], display: None, }; diff --git a/agent_issuance/src/startup_commands.rs b/agent_issuance/src/startup_commands.rs index 8ce42e27..ec19147d 100644 --- a/agent_issuance/src/startup_commands.rs +++ b/agent_issuance/src/startup_commands.rs @@ -1,4 +1,4 @@ -use agent_shared::config; +use agent_shared::{config, url_utils::UrlAppendHelpers}; use oid4vci::{ credential_format_profiles::{ w3c_verifiable_credentials::jwt_vc_json::{CredentialDefinition, JwtVcJson}, @@ -37,7 +37,7 @@ pub fn load_authorization_server_metadata(base_url: url::Url) -> IssuanceCommand IssuanceCommand::LoadAuthorizationServerMetadata { authorization_server_metadata: Box::new(AuthorizationServerMetadata { issuer: base_url.clone(), - token_endpoint: Some(base_url.join("auth/token").unwrap()), + token_endpoint: Some(base_url.append_path_segment("auth/token")), ..Default::default() }), } @@ -48,7 +48,7 @@ pub fn load_credential_issuer_metadata(base_url: url::Url) -> IssuanceCommand { credential_issuer_metadata: CredentialIssuerMetadata { credential_issuer: base_url.clone(), authorization_server: None, - credential_endpoint: base_url.join("openid4vci/credential").unwrap(), + credential_endpoint: base_url.append_path_segment("openid4vci/credential"), deferred_credential_endpoint: None, batch_credential_endpoint: None, credentials_supported: vec![], diff --git a/agent_shared/Cargo.toml b/agent_shared/Cargo.toml index b9f35e0b..be99bdcd 100644 --- a/agent_shared/Cargo.toml +++ b/agent_shared/Cargo.toml @@ -7,6 +7,7 @@ edition = "2021" config = { version = "0.13" } dotenvy = { version = "0.15" } tracing.workspace = true +url.workspace = true [features] test = [] diff --git a/agent_shared/src/config.rs b/agent_shared/src/config.rs index 4a2e1ad6..0a8febb4 100644 --- a/agent_shared/src/config.rs +++ b/agent_shared/src/config.rs @@ -25,8 +25,12 @@ pub fn config(package_name: &str) -> config::Config { /// Read environment variables for tests that can be used across packages #[cfg(feature = "test")] fn test_config() -> config::Config { + use std::env; + dotenvy::from_filename("agent_shared/tests/.env.test").ok(); + env::remove_var("AGENT_APPLICATION_BASE_PATH"); + config::Config::builder() .add_source(config::Environment::with_prefix("TEST")) .add_source(config::Environment::with_prefix("AGENT_CONFIG")) diff --git a/agent_shared/src/lib.rs b/agent_shared/src/lib.rs index 688bdd4d..0801cdc6 100644 --- a/agent_shared/src/lib.rs +++ b/agent_shared/src/lib.rs @@ -1,4 +1,8 @@ pub mod config; +pub mod url_utils; + +pub use ::config::ConfigError; +pub use url_utils::UrlAppendHelpers; /// Macro to read configuration using the package name as prefix. #[macro_export] diff --git a/agent_shared/src/url_utils.rs b/agent_shared/src/url_utils.rs new file mode 100644 index 00000000..976b0d0c --- /dev/null +++ b/agent_shared/src/url_utils.rs @@ -0,0 +1,90 @@ +pub trait UrlAppendHelpers { + fn append_path_segment(&self, file: &str) -> url::Url; +} + +fn create_trailing_slash_url(url: &url::Url) -> url::Url { + if !url.path().ends_with('/') { + let res = url::Url::parse(&format!("{}/", url)).unwrap(); + tracing::info!("res: {:?}", res); + res + } else { + url.clone() + } +} + +impl UrlAppendHelpers for url::Url { + fn append_path_segment(&self, file: &str) -> url::Url { + let mut path = file.to_string(); + + if path.starts_with('/') { + path.remove(0); + } + + let url = create_trailing_slash_url(self).join(&path); + + match url { + Ok(url) => url, + Err(err) => { + let err_str = format!("Segment can't be added: {:?}\n{:?}", path, err); + tracing::error!("{:?}", &err_str); + panic!("{:?}", &err_str); + } + } + } +} + +#[cfg(test)] +mod tests { + use crate::url_utils::UrlAppendHelpers; + use url::Url; + + #[test] + fn test_append_path() { + let url = Url::parse("https://test.example.com/unicore/").unwrap(); + let res: String = url.append_path_segment("/some-path/").into(); + + assert_eq!("https://test.example.com/unicore/some-path/", &res); + + let res: String = url.append_path_segment("some-path/").into(); + + assert_eq!("https://test.example.com/unicore/some-path/", &res); + + // With base path (no trailing slash) + let url = Url::parse("https://test.example.com/unicore").unwrap(); + let res: String = url.append_path_segment("/some-path/").into(); + + assert_eq!("https://test.example.com/unicore/some-path/", &res); + + let url = Url::parse("https://test.example.com").unwrap(); + let res: String = url.append_path_segment("/some-path/").into(); + + assert_eq!("https://test.example.com/some-path/", &res); + } + + #[test] + fn test_append_filename() { + let url = Url::parse("https://test.example.com/unicore/").unwrap(); + let res: String = url.append_path_segment("/some-file.txt").into(); + + assert_eq!("https://test.example.com/unicore/some-file.txt", &res); + + let res: String = url.append_path_segment("some-file.txt").into(); + + assert_eq!("https://test.example.com/unicore/some-file.txt", &res); + + let url = Url::parse("https://test.example.com/unicore").unwrap(); + let res: String = url.append_path_segment("/some-file.txt").into(); + + assert_eq!("https://test.example.com/unicore/some-file.txt", &res); + + let url = Url::parse("https://test.example.com").unwrap(); + let res: String = url.append_path_segment("/some-file.txt").into(); + + assert_eq!("https://test.example.com/some-file.txt", &res); + + let url = Url::parse("https://test.example.com/").unwrap(); + let res: String = url.append_path_segment("/some-file.txt").into(); + + assert_eq!("https://test.example.com/some-file.txt", &res); + } +}