diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index ea5c8c2..b91c673 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,8 +1,7 @@ -FROM mcr.microsoft.com/devcontainers/base:ubuntu-22.04 AS base +FROM mcr.microsoft.com/devcontainers/base:ubuntu-22.04 -RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ - && apt-get -y install --no-install-recommends \ - build-essential clang lld make gdb cmake ninja-build \ +RUN apt-get update && apt-get -y install --no-install-recommends \ + build-essential clang lld gdb make cmake ninja-build \ crossbuild-essential-arm64 \ git curl wget unzip sudo \ pip \ @@ -13,4 +12,6 @@ RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ RUN pip install conan alembic psycopg2-binary sqlalchemy-utils -COPY to-dos-conan-profile.conf /root/.conan2/profiles/default \ No newline at end of file +# If this profile is not copied, the profile must be specified via an argument +# when calling `conan`. +COPY ./to-dos-conan-profile.conf /root/.conan2/profiles/default \ No newline at end of file diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index a254b35..ebb279d 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -6,13 +6,35 @@ "dockerfile": "Dockerfile" }, // Use 'forwardPorts' to make a list of ports inside the container available locally. - // "forwardPorts": [], + "forwardPorts": [ + 4501, + 7501, + 9501 + ], + // More information about 'portsAttributes' here: https://containers.dev/implementors/json_reference/#port-attributes + "portsAttributes": { + "4501": { + "label": "VSCode Dev Container API Running Port", + "requireLocalPort": true + }, + "7501": { + "label": "VSCode Dev Container DB Running Port", + "requireLocalPort": true + }, + "9501": { + "label": "VSCode Dev Container PgAdmin Running Port", + "requireLocalPort": true + } + }, // Use 'postCreateCommand' to run commands after the container is created. - "postCreateCommand": "conan remote add local-recipes ./deps --type=local-recipes-index", // Add a local recipes store for odb libraries + "postCreateCommand": "conan remote add local-recipes ./deps --type=local-recipes-index --force", // Add a local recipes store for odb libraries. Need force flag for redefine while second time container is up // Configure tool-specific properties. // "customizations": {}, // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. "remoteUser": "root", + "remoteEnv": { + "LOCAL_WORKSPACE_FOLDER": "${localWorkspaceFolder}" + }, "runArgs": [ // This is necessary to avoid having to manually export environment variables from // the .env file in every terminal session. @@ -24,10 +46,10 @@ "mounts": [ // This mounts increase future building on devcontainer in case any reseting of container volumes // Mount caching of build folder - "source=dev-build-cache,target=${containerWorkspaceFolder}/build,type=volume", + "source=dev-build-cache,target=${containerWorkspaceFolder}/build,type=volume", // Mount caching of conan dependencies builds - "source=dev-conan-cache,target=~/.conan2,type=volume" - ], + "source=dev-conan-cache,target=/root/.conan2,type=volume" + ], "customizations": { "vscode": { "extensions": [ diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..bf4899d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,19 @@ +.github/ +.vscode/ +build/ +ci/ +scripts/ +e2e/ +docs/ +.clang-format +.clang-tidy +.env +.env.example +.gitattributes +.gitignore +CMakeUserPresets.json +docker-compose.yml +Dockerfile +LICENSE +pgAdmin.json +README.md \ No newline at end of file diff --git a/.github/workflows/reusable-docker-build-and-push.yml b/.github/workflows/.reusable-docker-build-and-push.yml similarity index 76% rename from .github/workflows/reusable-docker-build-and-push.yml rename to .github/workflows/.reusable-docker-build-and-push.yml index 851d641..dec87e5 100644 --- a/.github/workflows/reusable-docker-build-and-push.yml +++ b/.github/workflows/.reusable-docker-build-and-push.yml @@ -7,17 +7,18 @@ name: Publish Docker image # if you build => deploy => run e2e against prod it will build the image 3 times! on: # to allow to wait for a docker image to be published to proceed in another workflow - # workflow_call: - # PR-based activation has been added, as there are no workflows that trigger this. - # After adding a workflow with tests, you must revert the change and leave only the `workflow_call`. - pull_request: - types: [opened, synchronize] + workflow_call: + # This event has been added for debugging purposes; there is currently no workflow + # that triggers when changes are made to the master branch. + push: + branches: + - master jobs: build-amd64: runs-on: ubuntu-24.04 steps: - - name: Check out the repo + - name: Checkout the repo uses: actions/checkout@v4 # this is needed to address this issue according to the comment https://github.com/devcontainers/ci/issues/271#issuecomment-2301764487 @@ -26,10 +27,14 @@ jobs: run: | echo "REGISTRY_IMAGE=ghcr.io/${GITHUB_REPOSITORY,,}" >>${GITHUB_ENV} + # we need to remove the / characters from the branch name, + # as they will cause an error when saving the cache - name: Prepare run: | platform=linux/amd64 echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV + BRANCH_NAME="${GITHUB_REF_NAME//[^a-zA-Z0-9._-]/-}" + echo "BRANCH_NAME=${BRANCH_NAME}" >> $GITHUB_ENV - name: Docker meta id: meta @@ -59,6 +64,15 @@ jobs: labels: ${{ steps.meta.outputs.labels }} tags: ${{ env.REGISTRY_IMAGE }} outputs: type=image,push-by-digest=true,name-canonical=true,push=true + # The `cache-from` and `cache-to` instructions allow you to load and unload the image cache on ghcr.io, + # respectively. The `mode=max` option allows each instruction in the Dockerfile to be cached separately, + # regardless of the build stage (in the case of a multi-stage build), which speeds up image builds + # significantly if there have been no changes to the instruction. + # More info: + # - https://github.com/docker/build-push-action/ + # - https://docs.docker.com/build/cache/backends/#cache-mode + cache-from: type=registry,ref=${{ env.REGISTRY_IMAGE }}:cache-amd64-${{ env.BRANCH_NAME }} + cache-to: type=registry,ref=${{ env.REGISTRY_IMAGE }}:cache-amd64-${{ env.BRANCH_NAME }},mode=max - name: Export digest run: | @@ -86,10 +100,14 @@ jobs: run: | echo "REGISTRY_IMAGE=ghcr.io/${GITHUB_REPOSITORY,,}" >>${GITHUB_ENV} + # we need to remove the / characters from the branch name, + # as they will cause an error when saving the cache - name: Prepare run: | platform=linux/arm64 echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV + BRANCH_NAME="${GITHUB_REF_NAME//[^a-zA-Z0-9._-]/-}" + echo "BRANCH_NAME=${BRANCH_NAME}" >> $GITHUB_ENV - name: Docker meta id: meta @@ -119,6 +137,8 @@ jobs: labels: ${{ steps.meta.outputs.labels }} tags: ${{ env.REGISTRY_IMAGE }} outputs: type=image,push-by-digest=true,name-canonical=true,push=true + cache-from: type=registry,ref=${{ env.REGISTRY_IMAGE }}:cache-arm64-${{ env.BRANCH_NAME }} + cache-to: type=registry,ref=${{ env.REGISTRY_IMAGE }}:cache-arm64-${{ env.BRANCH_NAME }},mode=max - name: Export digest run: | @@ -165,9 +185,10 @@ jobs: - name: Check out the repo uses: actions/checkout@v4 - - name: Add SEMVER_VERSION Env Var with Value from __version File - run: | - echo "SEMVER_VERSION=$(cat __version)" >>${GITHUB_ENV} + # TODO: This needs to be reverted once the semantic versioning workflow is added + # - name: Add SEMVER_VERSION Env Var with Value from __version File + # run: | + # echo "SEMVER_VERSION=$(cat __version)" >>${GITHUB_ENV} - name: Extract metadata (tags, labels) for Docker id: meta @@ -180,7 +201,8 @@ jobs: # full length sha type=sha,format=long # SemVer human readable version - type=raw,value=${{ env.SEMVER_VERSION }} + # TODO: This needs to be reverted once the semantic versioning workflow is added + # type=raw,value=${{ env.SEMVER_VERSION }} # set latest tag for default branch # https://github.com/docker/metadata-action/issues/171 explains how to tag latest only on default branch type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }} diff --git a/.github/workflows/e2e-tests-on-pull-request.yml b/.github/workflows/e2e-tests-on-pull-request.yml new file mode 100644 index 0000000..d98b936 --- /dev/null +++ b/.github/workflows/e2e-tests-on-pull-request.yml @@ -0,0 +1,103 @@ +name: E2E Tests in PR + +on: + pull_request: + types: [opened, synchronize, reopened] + +jobs: + # this is needed to wait for the new docker image to be build and published to the registry + # so that we can use the image to run the service of the needed commit related version as part of local-env + # the idea is taken from here https://stackoverflow.com/a/71489231 + docker-build-and-push: + uses: ./.github/workflows/.reusable-docker-build-and-push.yml + + e2e-test-against-local-env: + runs-on: ubuntu-24.04 + needs: [docker-build-and-push] + steps: + - name: Checkout local-env + uses: actions/checkout@v4 + with: + repository: TourmalineCore/to-dos-local-env + + # we need to make sure that the values-to-dos-api-cpp.yaml.gotmpl file hasn't been modified + # in the `to-dos-local-env` repository, to ensure that the test runs as expected. + - name: Verify values file checksum + run: | + # the awk command is required to extract the target hash from the output of the sha256sum command + VALUES_HASH=$(sha256sum deploy/values-to-dos-api-cpp.yaml.gotmpl | awk '{ print $1 }') + if [ "$VALUES_HASH" != ${{ vars.TO_DOS_API_CPP_LOCAL_ENV_HELMFILE_HASH }} ]; then + echo "ERROR: checksum mismatch" + echo "Expected: 88d14f527d1c39d9af93ddbe2f41462f01b523bdc340b4c62cb72c31a4c46d1f" + echo "Actual: $VALUES_HASH" + exit 1 + fi + + - name: Deploy Local Env to Kind k8s + uses: devcontainers/ci@v0.3 + with: + runCmd: | + # we need to override "latest" image tag of ui inside local-env to run e2e against the current commit api version and not against latest from master + # We tried to use yq to change the image tag, but in the values files for helmfile we have non-yaml code that yq can`t parse or ignore + # so for that reason we use Stream EDitor which can find needed string using regular expressions and change it to a new value + # The -i flag is needed to write new image tag directly to values file + sed -i "0,/tag:.*/s//tag: \"sha-${{ github.event.pull_request.head.sha }}\"/" deploy/values-to-dos-api-cpp.yaml.gotmpl + + # we also need to update the commit sha in the initContainer so that the latest image is used inside it + sed -i "0,/image:.*/s//image: \"ghcr.io/tourmalinecore/to-dos-api-cpp:sha-${{ github.event.pull_request.head.sha }}\"/" deploy/values-to-dos-api-cpp.yaml.gotmpl + + # we need to override "latest" ref of service chart inside local-env to run tests against the current commit service chart version and not against latest from master + sed -i "0,/git+https:\/\/github.com\/TourmalineCore\/${{ github.event.repository.name }}.git?ref=.*/s//git+https:\/\/github.com\/TourmalineCore\/${{ github.event.repository.name }}.git?ref=${{ github.event.pull_request.head.sha }}/" deploy/helmfile.yaml.gotmpl + + sed -i "0,/git::https:\/\/github.com\/TourmalineCore\/${{ github.event.repository.name }}.git@\/ci\/values.yaml?ref=.*/s//git::https:\/\/github.com\/TourmalineCore\/${{ github.event.repository.name }}.git@\/ci\/values.yaml?ref=${{ github.event.pull_request.head.sha }}/" deploy/helmfile.yaml.gotmpl + + kind create cluster --name to-dos --config kind-local-config.yaml --kubeconfig ./.to-dos-cluster-kubeconfig + # we need to properly expose KUBECONFIG as an absolute path, pwd prints current working directory path + export KUBECONFIG=$(pwd)/.to-dos-cluster-kubeconfig + + # When called, Makefile targets execute a sequence of commands. + # The targets are defined in the Makefile located in the root of the to-dos-local-env repository. + make deploy-with-cpp-api + push: never + + - name: Check out the repo + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'temurin' + + - name: Download Karate JAR + run: | + curl -L https://github.com/karatelabs/karate/releases/download/v1.5.1/karate-1.5.1.jar -o karate.jar + + - name: Run E2E Tests Against Local Env + run: | + java -jar karate.jar . + env: + "API_ROOT_URL": "http://localhost:30080/api/to-dos-api" + + e2e-karate-tests-in-docker-compose: + runs-on: ubuntu-24.04 + steps: + - name: Checkout to repo + uses: actions/checkout@v4 + + # If you don't create a .env file, the services in Docker Compose + # won't be able to start because they use this file. + - name: Create .env file + run: | + echo "API_HOST=0.0.0.0" >> .env + echo "API_PORT=80" >> .env + echo "API_LOG_LEVEL=INFO" >> .env + echo "API_NUMBER_OF_THREADS=1" >> .env + echo "POSTGRES_HOST=0.0.0.0" >> .env + echo "POSTGRES_PORT=5432" >> .env + echo "POSTGRES_DB=to-dos-api-cpp-db" >> .env + echo "POSTGRES_USER=postgres" >> .env + echo "POSTGRES_PASSWORD=password" >> .env + + - name: Run service via docker-compose and run Karate-tests + run: docker compose --profile MockForPullRequest up --exit-code-from to-dos-api-cpp-karate-tests diff --git a/.gitignore b/.gitignore index cdaae0b..4873f56 100644 --- a/.gitignore +++ b/.gitignore @@ -37,11 +37,13 @@ *.out *.app -# debug information files +# Debug information files *.dwo # Build files - /build __pycache__ .env + +# Service files +.DS_Store diff --git a/CMakeLists.txt b/CMakeLists.txt index 92d1210..2b209b9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -33,12 +33,12 @@ target_link_libraries(${PROJECT_NAME} ) # Tests -if (ENV{EXCLUDE_UNIT_TESTS_FROM_BUILD} STREQUAL "true") +if ("$ENV{EXCLUDE_UNIT_TESTS_FROM_BUILD}" STREQUAL "true") # Disable test here needed for uncompatible builds, bc `enable_testing` is run executable tests file for collecting tests. # E.g. if executable tests are builded for another OS or CPU arch. message(STATUS "UNIT TEST DISABLED") else() message(STATUS "UNIT TEST ENABLED") enable_testing() - add_subdirectory(test) + add_subdirectory(unit) endif() diff --git a/Dockerfile b/Dockerfile index d5cabb7..81e6adc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,13 +7,11 @@ WORKDIR /app EXPOSE 80 # pip is installed here because it needs to be available in both the build and final stages -RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ - && apt-get -y install --no-install-recommends pip +RUN apt-get update && apt-get -y install --no-install-recommends pip FROM base AS build WORKDIR /src -COPY . . RUN apt-get -y install --no-install-recommends \ build-essential clang lld make cmake ninja-build gdb \ @@ -23,12 +21,26 @@ RUN apt-get -y install --no-install-recommends \ RUN pip install conan +# this is necessary so that Conan can install the dependencies +COPY conanfile.py /src/ +COPY .devcontainer/to-dos-conan-profile.conf /src/.devcontainer/ +COPY deps/ /src/deps/ + # this is necessary so that Conan can see the local dependency recipes RUN conan remote add local-recipes ./deps --type=local-recipes-index +RUN conan install . --build=missing \ + --profile:all=.devcontainer/to-dos-conan-profile.conf \ + # this is necessary because, by default, the `build_type` property in the profile is set to `Debug` + --settings:host="build_type=Release" + +# We cannot copy all the content before running `conan install`, because +# otherwise the image layers cannot be cached in the pipeline +COPY . . + RUN conan build . --build=missing \ --profile:all=.devcontainer/to-dos-conan-profile.conf \ - # This is necessary because, by default, the `build_type` property in the profile is set to `Debug` + # this is necessary because, by default, the `build_type` property in the profile is set to `Debug` --settings:host="build_type=Release" FROM base AS final @@ -39,7 +51,7 @@ RUN pip install alembic psycopg2-binary sqlalchemy-utils WORKDIR /app # alembic needs this to apply the migrations correctly -COPY --from=build /src/alembic/* ./alembic/ +COPY --from=build /src/alembic/ ./alembic/ COPY --from=build /src/build/Release/to-dos-api . diff --git a/Makefile b/Makefile index fed04ba..ecfd602 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,13 @@ # This is necessary so that environment variables from .env # are visible when Makefile commands are executed -include .env +-include .env export +# By default, Make treats all targets as files; unless you specify that targets +# are commands, the instructions will be skipped if a file with the target’s name exists. +# More info: https://www.gnu.org/software/make/manual/make.html#Phony-Targets +.PHONY: create-migration apply-migrations run run-code-analysis run-e2e-tests-within-docker-compose + # Generate a new Alembic migration with autogenerate create-migration: @cd ./alembic && \ @@ -19,5 +24,5 @@ run: apply-migrations ./build/Debug/to-dos-api # Run clang-tidy static analysis -run-tidy: - @run-clang-tidy -p build/Debug +run-code-analysis: + @run-clang-tidy -p build/Debug \ No newline at end of file diff --git a/README.md b/README.md index 0ec184c..7fd1a93 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,18 @@ To launch the executable, click Launch in the CMake extension. Alternatively, you can use make targets. - To run the application, use the `make run` command. When the command is executed, the project's dependencies will be checked and, if necessary, installed and compiled. +## Allocated Ports & Services + +| Service Name | Api in Dev Container/Codespaces | Api in IDE | Api in Docker Compose | Db in Docker Compose | Reserved for MockServer in Docker Compose | PgAdmin in Docker Compose | +| :------------------------- | :-----------------------------: | :--------: | :-------------------: | :-------------------: | :-------------------------: | :-------------------------: | +| to-dos-api-cpp | 4501 | 5501 | 6501 | 7501 | 8501 | 9501 | + +Full docs about the allocated ports, reasoning, and the other services bindings in this infrastructre setup are available [here](https://github.com/TourmalineCore/inner-circle-documentation/blob/master/code-style/api-code-style.md#ports). + +You can go to `Ports` tab in the `Terminal` parent panel to find available services. + +The most useful is `PgAdmin` http://localhost:9501 (password is `postgres`). + ## Linters The project includes the `clang-tidy` code analyzer and the `clang-format` formatter. Configuration files are located in the project root: `.clang-tidy` and `.clang-format`, respectively. @@ -104,4 +116,4 @@ The alembic tool is used to work with migrations. To work with it, you need to m Alternatively, you can use make targets. - To create a migration, run the command `make create-migration name=`, where `name` is the name of the migration. You can also use `make create-migration`, in which case the migration will be named after the current date and time. -- To apply the migrations, run the `make apply-migrations` command. +- To apply the migrations, run the `make apply-migrations` command. \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 37cd437..4c1fe33 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,14 +1,105 @@ -version: '3.9' services: - postgresql: - container_name: postgresql + to-dos-api-cpp-db: + container_name: to-dos-api-cpp-db image: postgres:14-alpine + profiles: + - DbOnly + - MockForPullRequest env_file: - .env + ports: + - "7501:5432" + healthcheck: + test: [ "CMD", "/bin/bash", "-c", "pg_isready -U postgres" ] + interval: 10s + timeout: 5s + retries: 5 + networks: + - to-dos-api-cpp-network + + # https://event-driven.io/en/automatically_connect_pgadmin_to_database/ + to-dos-api-cpp-pgadmin: + image: dpage/pgadmin4 + container_name: to-dos-api-cpp-pgadmin + profiles: + - DbOnly + depends_on: + to-dos-api-cpp-db: + condition: service_healthy + environment: + # TODO: We need to think of a way to synchronize the .env file used for the database service. + # Perhaps we could transfer the pgAdmin environment variables to the .env file. + POSTGRES_USER: postgres + POSTGRES_PASSWORD: password + POSTGRES_DB: to-dos-api-cpp-db + PGADMIN_DEFAULT_EMAIL: admin@admin.org + PGADMIN_DEFAULT_PASSWORD: postgres + PGADMIN_CONFIG_SERVER_MODE: 'False' + PGADMIN_CONFIG_MASTER_PASSWORD_REQUIRED: 'False' + # these 2 variables PGADMIN_CONFIG_PROXY_X_HOST_COUNT and PGADMIN_CONFIG_PROXY_X_PREFIX_COUNT + # are needed to enable PgAdmin in Codespaces scenario when it is behind a reverse proxy + # https://github.com/orgs/community/discussions/17918 + PGADMIN_CONFIG_PROXY_X_HOST_COUNT: 1 + PGADMIN_CONFIG_PROXY_X_PREFIX_COUNT: 1 + ports: + - 9501:80 + networks: + - to-dos-api-cpp-network volumes: - - postgresql-data:/var/lib/postgresql/data + # this is needed so that we can spin it up from within Dev Container where LOCAL_WORKSPACE_FOLDER is defined and from a simple OS terminal of the repo root + # list of available config properties can be found here in the source code https://github.com/pgadmin-org/pgadmin4/blob/00dbe58125f0304186f1af5374c9c43bc22d0410/web/pgadmin/utils/__init__.py#L505 + # Password is no longer a part of this list as it seems to be when the linked article was written + - ${LOCAL_WORKSPACE_FOLDER:-.}/pgAdmin.json:/pgadmin4/servers.json + + to-dos-api-cpp: + container_name: to-dos-api-cpp + profiles: + - MockForPullRequest + depends_on: + to-dos-api-cpp-db: + condition: service_healthy + build: + dockerfile: ./Dockerfile + context: . + args: + EXCLUDE_UNIT_TESTS_FROM_BUILD: "true" + # We need to override the entrypoint to apply the migrations to the database before the API starts + entrypoint: [ "/bin/bash", "-c", "alembic -c ./alembic/alembic.ini upgrade head && ./to-dos-api" ] + env_file: + - .env + # Unless you override the host to the service name, the API will + # not be able to locate the database container on the network + environment: + # Without redefining the host to 0.0.0.0 (all network interfaces), + # connections from outside the container will not be possible + - API_HOST=0.0.0.0 + # To connect to the database service on the internal network, + # the internal network database credentials must be used. + - POSTGRES_HOST=to-dos-api-cpp-db + - POSTGRES_PORT=5432 ports: - - "5432:5432" + - 6501:80 + networks: + - to-dos-api-cpp-network + + to-dos-api-cpp-karate-tests: + container_name: 'to-dos-api-cpp-karate-tests' + profiles: + - MockForPullRequest + build: + dockerfile: ./KarateDockerfile + context: ./e2e + depends_on: + to-dos-api-cpp: + condition: service_healthy + command: [ "karate", "/karate" ] + volumes: + # similar to mock-server volumes we need to support both runs: from Dev Container and from OS + - ${LOCAL_WORKSPACE_FOLDER:-.}:/karate + environment: + API_ROOT_URL: "http://to-dos-api-cpp/api" + networks: + - to-dos-api-cpp-network -volumes: - postgresql-data: +networks: + to-dos-api-cpp-network: diff --git a/e2e/KarateDockerfile b/e2e/KarateDockerfile new file mode 100644 index 0000000..ff828ed --- /dev/null +++ b/e2e/KarateDockerfile @@ -0,0 +1,9 @@ +FROM eclipse-temurin:17-jre-noble + +RUN apt-get update && apt-get install -y curl unzip && apt-get autoremove -y \ + && apt-get clean -y \ + && rm -rf /var/lib/apt/lists/* + +RUN curl -o karate.jar -L 'https://github.com/intuit/karate/releases/download/v1.5.1/karate-1.5.1.jar' + +ENTRYPOINT ["java", "-jar", "karate.jar"] \ No newline at end of file diff --git a/e2e/js-utils.js b/e2e/js-utils.js new file mode 100644 index 0000000..cc615a2 --- /dev/null +++ b/e2e/js-utils.js @@ -0,0 +1,9 @@ +function fn() { + return { + getEnvVariable: function (variable) { + var System = Java.type('java.lang.System'); + + return System.getenv(variable); + }, + } +} \ No newline at end of file diff --git a/e2e/to-dos-happy-path.feature b/e2e/to-dos-happy-path.feature new file mode 100644 index 0000000..7bc5ad6 --- /dev/null +++ b/e2e/to-dos-happy-path.feature @@ -0,0 +1,65 @@ +Feature: To-Dos + # https://github.com/karatelabs/karate/issues/1191 + # https://github.com/karatelabs/karate?tab=readme-ov-file#karate-fork + + Background: + * header Content-Type = 'application/json' + + Scenario: Happy Path + + * def jsUtils = read('./js-utils.js') + * def apiRootUrl = jsUtils().getEnvVariable('API_ROOT_URL') + + # Step 1: Create a new todo + * def randomName = '[API-E2E]-test-todo-' + Math.random() + + Given url apiRootUrl + And path 'to-dos' + And request + """ + { + "name": "#(randomName)" + } + """ + When method POST + Then status 201 + + * print response + * def todoId = response.todoId + + # Step 2: Verify that todo is in the list with the id and generated name + Given url apiRootUrl + And path 'to-dos' + When method GET + Then match response.toDos contains + """ + { + "id": #(todoId), + "name": "#(randomName)" + } + """ + + # Step 3: Complete the todo with the id (soft delete) + Given url apiRootUrl + And path 'to-dos/complete' + And request + """ + { + "toDoIds": [#(todoId)] + } + """ + When method POST + Then status 200 + + # Step 4: Delete the todo with the id (hard delete) + Given url apiRootUrl + And path 'to-dos' + And param toDoId = todoId + When method DELETE + Then status 200 + + # Step 5: Verify that todo is in the list with the id and generated name has been deleted + Given url apiRootUrl + And path 'to-dos' + When method GET + Then match response.toDos !contains { "name": "#(randomName)" } diff --git a/pgAdmin.json b/pgAdmin.json new file mode 100644 index 0000000..5867b69 --- /dev/null +++ b/pgAdmin.json @@ -0,0 +1,22 @@ +{ + "Servers": { + "1": { + "Group": "Servers", + "Name": "to-dos-api-cpp-db (Windows, macOS)", + "Host": "host.docker.internal", + "Port": 7501, + "MaintenanceDB": "to-dos-api-cpp-db", + "Username": "postgres", + "SSLMode": "prefer" + }, + "2": { + "Group": "Servers", + "Name": "to-dos-api-cpp-db (Ubuntu, Codespaces)", + "Host": "172.17.0.1", + "Port": 7501, + "MaintenanceDB": "to-dos-api-cpp-db", + "Username": "postgres", + "SSLMode": "prefer" + } + } +} \ No newline at end of file diff --git a/src/api/features/to-dos/to-dos-controller.cpp b/src/api/features/to-dos/to-dos-controller.cpp index 5ee7178..8f2f2bd 100644 --- a/src/api/features/to-dos/to-dos-controller.cpp +++ b/src/api/features/to-dos/to-dos-controller.cpp @@ -58,24 +58,26 @@ void ToDosController::addToDo(const HttpRequestPtr& req, std::functiongetJsonObject(); if (!json || !json->isMember("name")) { - Json::Value result; - result["status"] = "error"; - result["message"] = "Invalid JSON"; + jsonResponse["status"] = "error"; + jsonResponse["message"] = "Invalid JSON"; - auto resp = HttpResponse::newHttpJsonResponse(result); + auto resp = HttpResponse::newHttpJsonResponse(jsonResponse); resp->setStatusCode(k400BadRequest); callback(resp); return; } CreateToDoRequest request { json->get("name", "").asString() }; - (void) createToDoHandler_->handle(request); + auto handlerResponse = createToDoHandler_->handle(request); - auto resp = HttpResponse::newHttpResponse(); + jsonResponse["todoId"] = handlerResponse.id; + + auto resp = HttpResponse::newHttpJsonResponse(jsonResponse); resp->setStatusCode(k201Created); callback(resp); } diff --git a/src/app-config.cpp b/src/app-config.cpp index fc147d0..21eef29 100644 --- a/src/app-config.cpp +++ b/src/app-config.cpp @@ -2,76 +2,86 @@ AppConfig::AppConfig() { - setApiHost(getEnv("API_HOST", "0.0.0.0")); - setApiPort(getEnvInt("API_PORT", 80)); - setApiNumThreads(getEnvInt("API_NUMBER_OF_THREADS", 1)); - setDatabaseHost(getEnv("POSTGRES_HOST", "0.0.0.0")); - setDatabasePort(getEnv("POSTGRES_PORT", "5432")); - setDatabaseName(getEnv("POSTGRES_DB", "to-dos-api-cpp-db")); - setDatabaseUser(getEnv("POSTGRES_USER", "postgres")); - setDatabasePassword(getEnv("POSTGRES_PASSWORD", "password")); + setApiHost(getEnv("API_HOST")); + setApiPort(getEnvInt("API_PORT")); + setApiNumThreads(getEnvInt("API_NUMBER_OF_THREADS")); + setDatabaseHost(getEnv("POSTGRES_HOST")); + setDatabasePort(getEnv("POSTGRES_PORT")); + setDatabaseName(getEnv("POSTGRES_DB")); + setDatabaseUser(getEnv("POSTGRES_USER")); + setDatabasePassword(getEnv("POSTGRES_PASSWORD")); } AppConfig& AppConfig::GetInstance() { - static AppConfig instance; + static AppConfig instance; return instance; } -std::string AppConfig::getEnv(std::string name, std::string defaultValue) +std::string AppConfig::getEnv(std::string name) { char* value = std::getenv(name.c_str()); - if (value) - { - return std::string(value); - } - else + if (!value) { - LOG_WARN << "Failed to get the value of the " << name << " environment variable; the default value will be used: " << defaultValue; - return defaultValue; + throw std::invalid_argument("error: failed to extract the " + name + " environment variable value"); } + + return std::string(value); } -uint32_t AppConfig::getEnvInt(std::string name, uint32_t defaultValue) +std::uint64_t AppConfig::getEnvInt(std::string name) { char* value = std::getenv(name.c_str()); if (!value) - return defaultValue; - - try { - auto result = std::stoi(value); - auto limit = std::numeric_limits::max(); + throw std::invalid_argument("error: failed to extract the " + name + " environment variable value"); + } - if (result < 0 || result > limit) - throw std::overflow_error( - "error: an attempt to write a " + name + " value which is larger than what can be stored in a " + std::to_string(limit) - ); + int result; - return static_cast(result); + try + { + result = static_cast(std::stoul(value)); } catch (const std::exception& e) { - LOG_ERROR << e.what(); - return defaultValue; + throw std::invalid_argument("error: failed to parse " + name + ": " + e.what()); + } + + if (result < 0) + { + throw std::overflow_error("error: an attempt to write a " + name + " value which is less than 0"); } + + return result; } trantor::Logger::LogLevel AppConfig::parseLogLevel(const std::string& level) { // kTrace < kDebug < kInfo < kWarn < kError if (level == "TRACE") + { return trantor::Logger::kTrace; + } else if (level == "DEBUG") + { return trantor::Logger::kDebug; + } else if (level == "INFO") + { return trantor::Logger::kInfo; + } else if (level == "WARN") + { return trantor::Logger::kWarn; + } else + { + LOG_WARN << "Unknown log level value: " << level << ", defaulting to kError"; return trantor::Logger::kError; + } } void AppConfig::setApiHost(std::string apiHost) @@ -80,15 +90,15 @@ void AppConfig::setApiHost(std::string apiHost) LOG_DEBUG << "Update value: apiHost_=" << apiHost_; }; -void AppConfig::setApiPort(uint32_t apiPort) +void AppConfig::setApiPort(std::uint32_t apiPort) { apiPort_ = apiPort; LOG_DEBUG << "Update value: apiPort_=" << apiPort_; }; -void AppConfig::setApiNumThreads(uint32_t apiNumThreads) +void AppConfig::setApiNumThreads(std::uint32_t apiNumThreads) { - uint32_t numberOfThreads = apiNumThreads; + std::uint32_t numberOfThreads = apiNumThreads; if (numberOfThreads > 0) { @@ -136,14 +146,17 @@ void AppConfig::setDatabasePassword(std::string databasePassword) const std::string& AppConfig::getApiHost() const { return apiHost_; } -const uint32_t& AppConfig::getApiPort() const +const std::uint64_t& AppConfig::getApiPort() const { return apiPort_; } -const uint32_t& AppConfig::getApiNumThreads() const +const std::uint64_t& AppConfig::getApiNumThreads() const { return apiNumThreads_; } const trantor::Logger::LogLevel AppConfig::getApiLogLevel() -{ return parseLogLevel(getEnv("API_LOG_LEVEL", "INFO")); } +{ + // The log level may change at runtime, so caching during initialization is not recommended + return parseLogLevel(getEnv("API_LOG_LEVEL")); +} const std::string& AppConfig::getDatabaseHost() const { return databaseHost_; } diff --git a/src/app-config.h b/src/app-config.h index d54d4c7..2403642 100644 --- a/src/app-config.h +++ b/src/app-config.h @@ -2,6 +2,7 @@ #include #include +#include #include #include @@ -19,8 +20,8 @@ class AppConfig AppConfig& operator=(const AppConfig&) = delete; std::string apiHost_; - uint32_t apiPort_; - uint32_t apiNumThreads_; + std::uint64_t apiPort_; + std::uint64_t apiNumThreads_; std::string databaseHost_; std::string databasePort_; @@ -40,14 +41,14 @@ class AppConfig * @param apiPort is port on which the API will be run * @return void */ - void setApiPort(uint32_t apiPort); + void setApiPort(std::uint32_t apiPort); /** * @brief Setter for the `apiNumThreads_` variable * @param apiNumThreads is number of threads on which the API will run * @return void */ - void setApiNumThreads(uint32_t apiNumThreads); + void setApiNumThreads(std::uint32_t apiNumThreads); /** * @brief Setter for the `databaseHost_` variable @@ -94,18 +95,16 @@ class AppConfig /** * @brief Function for retrieving the value of an environment variable * @param name is the name of the environment variable - * @param defaultValue is the value that will be used if the environment variable doesn't exist * @return std::string */ - static std::string getEnv(std::string name, std::string defaultValue); + static std::string getEnv(std::string name); /** * @brief Function that retrieves a value from an environment variable and converts it to an integer type * @param name is the name of the environment variable - * @param defaultValue is the value that will be used if the environment variable doesn't exist - * @return uint32_t + * @return std::uint64_t */ - static uint32_t getEnvInt(std::string name, uint32_t defaultValue); + static std::uint64_t getEnvInt(std::string name); /** * @brief Function for converting a string representing a logging level to the `trantor` type. Default is `trantor::Logger::kError` @@ -121,15 +120,15 @@ class AppConfig /** * @brief Getter for the `apiPort_` variable - * @return const uint32_t + * @return const std::uint64_t */ - const uint32_t& getApiPort() const; + const std::uint64_t& getApiPort() const; /** * @brief Getter for the `apiNumThreads_` variable - * @return const uint32_t + * @return const std::uint64_t */ - const uint32_t& getApiNumThreads() const; + const std::uint64_t& getApiNumThreads() const; /** * @brief Getter for the `apiLogLevel_` variable diff --git a/test/CMakeLists.txt b/unit/CMakeLists.txt similarity index 100% rename from test/CMakeLists.txt rename to unit/CMakeLists.txt diff --git a/test/addition-operation.cpp b/unit/addition-operation.cpp similarity index 100% rename from test/addition-operation.cpp rename to unit/addition-operation.cpp diff --git a/test/addition-operation.h b/unit/addition-operation.h similarity index 100% rename from test/addition-operation.h rename to unit/addition-operation.h diff --git a/test/test_main.cpp b/unit/test_main.cpp similarity index 100% rename from test/test_main.cpp rename to unit/test_main.cpp