diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..43f285b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,80 @@ +name: CI + +on: + push: + branches: [main, develop] + pull_request: + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + runs-on: ubuntu-latest + container: + image: swift:6.3-noble + services: + postgres: + image: postgres:latest + env: + POSTGRES_USER: vapor_username + POSTGRES_PASSWORD: vapor_password + POSTGRES_DB: vapor_test + ports: + - 5432:5432 + env: + DATABASE_HOST: postgres + DATABASE_USERNAME: vapor_username + DATABASE_PASSWORD: vapor_password + DATABASE_NAME: vapor_test + steps: + - uses: actions/checkout@v4 + - name: Run tests with coverage + run: swift test --enable-code-coverage + - name: Generate coverage report + if: always() + run: | + BIN_PATH="$(swift build --show-bin-path)" + XCTEST_PATH="$(find "$BIN_PATH" -name '*.xctest')" + IGNORE_FILENAME_REGEX="(\.build|TestUtils|Tests)" + + llvm-cov export "$XCTEST_PATH" \ + --format=lcov \ + --instr-profile=".build/debug/codecov/default.profdata" \ + --ignore-filename-regex="$IGNORE_FILENAME_REGEX" > coverage.lcov + + { + echo "### Code coverage" + echo + echo '```' + llvm-cov report "$XCTEST_PATH" \ + --instr-profile=".build/debug/codecov/default.profdata" \ + --ignore-filename-regex="$IGNORE_FILENAME_REGEX" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + - name: Upload coverage report + if: always() + uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: coverage.lcov + + lint: + runs-on: ubuntu-latest + permissions: + contents: read + checks: write + steps: + - uses: actions/checkout@v4 + - uses: reviewdog/action-setup@v1 + - name: Run SwiftLint + run: | + docker run --rm -v "$PWD:/work" -w /work ghcr.io/realm/swiftlint:latest \ + lint --reporter checkstyle > swiftlint-report.xml + - name: Annotate with reviewdog + env: + REVIEWDOG_GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + reviewdog -f=checkstyle -name=swiftlint -reporter=github-check \ + -filter-mode=nofilter -fail-level=error < swiftlint-report.xml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..5423295 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,93 @@ +name: Build & Deploy + +on: + push: + branches: [develop, main] + workflow_dispatch: + +concurrency: + group: deploy-${{ github.ref_name }} + +jobs: + build: + if: github.event_name == 'push' + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-buildx-action@v3 + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: ghcr.io/megamek/api-data:${{ github.ref_name }} + + deploy-staging: + needs: build + if: github.event_name == 'push' && github.ref == 'refs/heads/develop' + runs-on: ubuntu-latest + environment: + name: staging + url: https://api.battletech.dev + steps: + - uses: actions/checkout@v4 + - name: Install Ansible + run: | + python3 -m pip install --upgrade pip + pip install ansible + ansible-galaxy collection install community.docker + - name: Configure SSH + run: | + mkdir -p ~/.ssh + echo "${{ secrets.DEPLOY_SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + ssh-keyscan -H api.battletech.dev >> ~/.ssh/known_hosts + - name: Write Ansible template vars + run: | + echo "${{ secrets.STAGING_DATABASE_HOST }}" > ansible/templates/database_host + echo "${{ secrets.STAGING_DATABASE_NAME }}" > ansible/templates/database_name + echo "${{ secrets.STAGING_DATABASE_PASSWORD }}" > ansible/templates/database_password + echo "${{ secrets.STAGING_DATABASE_USERNAME }}" > ansible/templates/database_username + echo "${{ github.actor }}" > ansible/templates/username + echo "${{ secrets.GHCR_READ_TOKEN }}" > ansible/templates/password + echo "${{ secrets.SENDGRID_API_KEY }}" > ansible/templates/sendgrid + - name: Deploy + run: ansible-playbook ansible/staging.yml + + deploy-production: + if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + environment: + name: production + url: https://api.quartermaster-command.services + steps: + - uses: actions/checkout@v4 + - name: Install Ansible + run: | + python3 -m pip install --upgrade pip + pip install ansible + ansible-galaxy collection install community.docker + - name: Configure SSH + run: | + mkdir -p ~/.ssh + echo "${{ secrets.DEPLOY_SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + ssh-keyscan -H api.battletech.games >> ~/.ssh/known_hosts + - name: Write Ansible template vars + run: | + echo "${{ secrets.PRODUCTION_DATABASE_HOST }}" > ansible/templates/database_host + echo "${{ secrets.PRODUCTION_DATABASE_NAME }}" > ansible/templates/database_name + echo "${{ secrets.PRODUCTION_DATABASE_PASSWORD }}" > ansible/templates/database_password + echo "${{ secrets.PRODUCTION_DATABASE_USERNAME }}" > ansible/templates/database_username + echo "${{ github.actor }}" > ansible/templates/username + echo "${{ secrets.GHCR_READ_TOKEN }}" > ansible/templates/password + echo "${{ secrets.SENDGRID_API_KEY }}" > ansible/templates/sendgrid + - name: Deploy + run: ansible-playbook ansible/production.yml diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..bb01745 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,45 @@ +name: Publish DocC to GitHub Pages + +on: + push: + branches: [develop] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + container: + image: swift:6.3-noble + steps: + - uses: actions/checkout@v4 + - name: Generate DocC documentation + run: | + swift package --allow-writing-to-directory ./docs \ + generate-documentation \ + --target App \ + --disable-indexing \ + --transform-for-static-hosting \ + --hosting-base-path ${{ github.event.repository.name }} \ + --output-path ./docs + - uses: actions/upload-pages-artifact@v3 + with: + path: docs + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index 075eb37..faad26f 100644 --- a/.gitignore +++ b/.gitignore @@ -18,7 +18,6 @@ ansible/templates/database* ansible/templates/password ansible/templates/username ansible/templates/sendgrid -ansible/templates/redis_url *.profraw coverage.info coverage/* diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml deleted file mode 100644 index 341508d..0000000 --- a/.gitlab-ci.yml +++ /dev/null @@ -1,97 +0,0 @@ -stages: - - build - - test - - deploy - - review - - dast - - staging - - canary - - production - - incremental rollout 10% - - incremental rollout 25% - - incremental rollout 50% - - incremental rollout 100% - - performance - - release - - cleanup - -include: - - template: Auto-DevOps.gitlab-ci.yml - - project: open-source/ci-templates - ref: "main" - file: "/ci/auto-tag-release.gitlab-ci.yml" - -sast: - stage: test - -code_quality: - image: - name: ghcr.io/realm/swiftlint:latest - pull_policy: if-not-present - script: - - swiftlint lint --reporter codeclimate --output gl-code-quality-report.json - artifacts: - paths: [gl-code-quality-report.json] - -test: - image: - name: swift:6.0-noble - pull_policy: if-not-present - stage: test - services: - - postgres:latest - variables: - POSTGRES_USER: vapor_username - POSTGRES_PASSWORD: vapor_password - POSTGRES_ENABLED: "true" - POSTGRES_DB: $CI_COMMIT_SHA - DATABASE_HOST: postgres - DATABASE_USERNAME: $POSTGRES_USER - DATABASE_PASSWORD: $POSTGRES_PASSWORD - DATABASE_NAME: $POSTGRES_DB - script: - - scripts/code_coverage.sh - coverage: '/TOTAL.*\s+(\d+\.\d+)%/' - -staging: - dependencies: - - build - stage: staging - script: - - echo $DB_HOST > ansible/templates/database_host - - echo $DB_NAME > ansible/templates/database_name - - echo $DB_PASSWORD > ansible/templates/database_password - - echo $DB_USER > ansible/templates/database_username - - echo $CI_REGISTRY_USER > ansible/templates/username - - echo $CI_REGISTRY_PASSWORD > ansible/templates/password - - echo $SENDGRID_API_KEY > ansible/templates/sendgrid - - ansible-playbook ansible/staging.yml - environment: - name: staging - url: https://api.battletech.dev - rules: - - if: "$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH" - tags: - - deploy - -production: - dependencies: - - build - stage: production - script: - - echo $DATABASE_HOST > ansible/templates/database_host - - echo $DATABASE_NAME > ansible/templates/database_name - - echo $DATABASE_PASSWORD > ansible/templates/database_password - - echo $DATABASE_USER > ansible/templates/database_username - - echo $CI_REGISTRY_USER > ansible/templates/username - - echo $CI_REGISTRY_PASSWORD > ansible/templates/password - - echo $SENDGRID_API_KEY > ansible/templates/sendgrid - - ansible-playbook ansible/production.yml - environment: - name: production - url: https://api.battletech.games - rules: - - if: "$CI_COMMIT_BRANCH == 'main'" - when: manual - tags: - - deploy diff --git a/Dockerfile b/Dockerfile index 9cc55aa..5e099aa 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,14 +1,13 @@ # ================================ # Build image # ================================ -FROM --platform=linux/amd64 swift:6.0-noble AS build +FROM swift:6.3-noble AS build # Install OS updates RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \ - && apt-get -q update \ - && apt-get -q dist-upgrade -y \ - && apt-get install -y libjemalloc-dev \ - && apt-get clean + && apt-get -q update \ + && apt-get -q dist-upgrade -y \ + && apt-get install -y libjemalloc-dev # Set up a build area WORKDIR /build @@ -24,25 +23,26 @@ RUN swift package resolve \ # Copy entire repo into container COPY . . +RUN mkdir /staging + # Build the application, with optimizations, with static linking, and using jemalloc # N.B.: The static version of jemalloc is incompatible with the static Swift runtime. -RUN swift build -c release \ - --product App \ - --static-swift-stdlib \ - -Xlinker -ljemalloc +RUN --mount=type=cache,target=/build/.build \ + swift build -c release \ + --product App \ + --static-swift-stdlib \ + -Xlinker -ljemalloc && \ + # Copy main executable to staging area + cp "$(swift build -c release --show-bin-path)/App" /staging && \ + # Copy resources bundled by SPM to staging area + find -L "$(swift build -c release --show-bin-path)" -regex '.*\.resources$' -exec cp -Ra {} /staging \; # Switch to the staging area WORKDIR /staging -# Copy main executable to staging area -RUN cp "$(swift build --package-path /build -c release --show-bin-path)/App" ./ - # Copy static swift backtracer binary to staging area RUN cp "/usr/libexec/swift/linux/swift-backtrace-static" ./ -# Copy resources bundled by SPM to staging area -RUN find -L "$(swift build --package-path /build -c release --show-bin-path)/" -regex '.*\.resources$' -exec cp -Ra {} ./ \; - # Copy any resources from the public directory and views directory if the directories exist # Ensure that by default, neither the directory nor any of its contents are writable. RUN [ -d /build/Public ] && { mv /build/Public ./Public && chmod -R a-w ./Public; } || true @@ -51,19 +51,19 @@ RUN [ -d /build/Resources ] && { mv /build/Resources ./Resources && chmod -R a-w # ================================ # Run image # ================================ -FROM --platform=linux/amd64 ubuntu:noble +FROM ubuntu:noble # Make sure all system packages are up to date, and install only essential packages. RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \ - && apt-get -q update \ - && apt-get -q dist-upgrade -y \ - && apt-get -q install -y \ - ca-certificates \ - libcurl4 \ - libjemalloc2 \ - libxml2 \ - tzdata \ - && apt-get clean + && apt-get -q update \ + && apt-get -q dist-upgrade -y \ + && apt-get -q install -y \ + libjemalloc2 \ + ca-certificates \ + tzdata \ + libcurl4 \ + libxml2 \ + && rm -r /var/lib/apt/lists/* # Create a vapor user and group with /app as its home directory RUN useradd --user-group --create-home --system --skel /dev/null --home-dir /app vapor diff --git a/Package.resolved b/Package.resolved index 83c7e88..921d537 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "aa12a0faf97478aea76d1e3f1c0d086467af12db3b663c62b37c17c1f0f57462", + "originHash" : "19ed40b96620e2aa189947bf4a79aebf54cd43e38ff89c6078c23da602588145", "pins" : [ { "identity" : "async-http-client", "kind" : "remoteSourceControl", "location" : "https://github.com/swift-server/async-http-client.git", "state" : { - "revision" : "333f51104b75d1a5b94cb3b99e4c58a3b442c9f7", - "version" : "1.25.2" + "revision" : "4603a8036d921ea999fadb742931546c341f4bd7", + "version" : "1.35.0" } }, { @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/vapor/async-kit.git", "state" : { - "revision" : "e048c8ee94967e8d8a1c2ec0e1156d6f7fa34d31", - "version" : "1.20.0" + "revision" : "6bbb83cbf9d886623a967a965c8fb1b73e6566f9", + "version" : "1.22.0" } }, { @@ -24,8 +24,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/vapor/console-kit.git", "state" : { - "revision" : "742f624a998cba2a9e653d9b1e91ad3f3a5dff6b", - "version" : "4.15.2" + "revision" : "32ad16dfc7677b927b225595ed18f3debb32f577", + "version" : "4.16.0" } }, { @@ -33,8 +33,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/vapor/fluent.git", "state" : { - "revision" : "223b27d04ab2b51c25503c9922eecbcdf6c12f89", - "version" : "4.12.0" + "revision" : "2fe9e36daf4bdb5edcf193e0d0806ba2074d2864", + "version" : "4.13.0" } }, { @@ -42,8 +42,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/vapor/fluent-kit.git", "state" : { - "revision" : "f12a66814657abd2e309c273a0825c4f978acebe", - "version" : "1.50.4" + "revision" : "ca609b2132bde05f9a2d7561e2864587c24fa7b9", + "version" : "1.57.0" } }, { @@ -51,8 +51,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/vapor/fluent-postgres-driver.git", "state" : { - "revision" : "095bc5a17ab3363167f4becb270b6f8eb790481c", - "version" : "2.10.1" + "revision" : "59bff45a41d1ece1950bb8a6e0006d88c1fb6e69", + "version" : "2.12.0" } }, { @@ -60,8 +60,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/vapor/leaf.git", "state" : { - "revision" : "bf48d2423c00292b5937c60166c7db99705cae47", - "version" : "4.4.1" + "revision" : "21852a898dc8681ec072f79968f1c21fb87da8cb", + "version" : "4.5.2" } }, { @@ -69,8 +69,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/vapor/leaf-kit.git", "state" : { - "revision" : "902c51288a6a1c3f19d9e33d1aebee066364c0e6", - "version" : "1.12.0" + "revision" : "6044b844caa858a0c5f2505ac166f5a057c990dc", + "version" : "1.14.2" } }, { @@ -87,8 +87,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/vapor/postgres-kit.git", "state" : { - "revision" : "f4d4b9e8db9a907644d67d6a7ecb5f0314eec1ad", - "version" : "2.14.0" + "revision" : "7c079553e9cda74811e627775bf22e40a9405ad9", + "version" : "2.15.1" } }, { @@ -96,8 +96,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/vapor/postgres-nio.git", "state" : { - "revision" : "5d817be55cae8b00003b7458944954558302d006", - "version" : "1.25.0" + "revision" : "f2188e05ba3546a76e61a5193c071b82c4d69a45", + "version" : "1.33.0" } }, { @@ -105,8 +105,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/vapor/queues.git", "state" : { - "revision" : "acdf38bd352fc6b31e2401c92c05888faf1e86b1", - "version" : "1.17.1" + "revision" : "4fa1ef91821fee04cce1982ba053ab37b88abfb9", + "version" : "1.18.0" } }, { @@ -114,8 +114,26 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/vapor/routing-kit.git", "state" : { - "revision" : "8c9a227476555c55837e569be71944e02a056b72", - "version" : "4.9.1" + "revision" : "1a10ccea61e4248effd23b6e814999ce7bdf0ee0", + "version" : "4.9.3" + } + }, + { + "identity" : "sendgrid", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor-community/sendgrid.git", + "state" : { + "revision" : "b7cb4922073abc6bdc8f97d1eff918cf2a35eacb", + "version" : "6.0.0" + } + }, + { + "identity" : "sendgrid-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor-community/sendgrid-kit.git", + "state" : { + "revision" : "cae5b098077ce54672f2aac6e856da6dba0236d6", + "version" : "3.1.0" } }, { @@ -123,8 +141,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/vapor/sql-kit.git", "state" : { - "revision" : "e0b35ff07601465dd9f3af19a1c23083acaae3bd", - "version" : "3.32.0" + "revision" : "3779cedb44b1f374f2cca261c6d28f206024a582", + "version" : "3.36.0" + } + }, + { + "identity" : "sql-kit-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor-community/sql-kit-extras.git", + "state" : { + "revision" : "80b0af782da1e35aa69fdd9f68fafefdee5de3be", + "version" : "0.2.0" } }, { @@ -141,8 +168,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-asn1.git", "state" : { - "revision" : "ae33e5941bb88d88538d0a6b19ca0b01e6c76dcf", - "version" : "1.3.1" + "revision" : "a9a5efd40eaf558a2bcd48d64b1d1646be686008", + "version" : "1.7.1" } }, { @@ -150,8 +177,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-async-algorithms.git", "state" : { - "revision" : "4c3ea81f81f0a25d0470188459c6d4bf20cf2f97", - "version" : "1.0.3" + "revision" : "3da39bbc4e687d4192af7c9cf4eab805745a0b9c", + "version" : "1.1.5" } }, { @@ -159,8 +186,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-atomics.git", "state" : { - "revision" : "cd142fd2f64be2100422d658e7411e39489da985", - "version" : "1.2.0" + "revision" : "0442cb5a3f98ab802acb777929fdb446bda11a34", + "version" : "1.3.1" + } + }, + { + "identity" : "swift-certificates", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-certificates.git", + "state" : { + "revision" : "89fbc3714264cce8db8e4ec51b64e01c3e28c6c5", + "version" : "1.19.3" } }, { @@ -168,8 +204,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections.git", "state" : { - "revision" : "671108c96644956dddcd89dd59c203dcdb36cec7", - "version" : "1.1.4" + "revision" : "a0cb0954ecb21e4e31b0070e6ed5674e8556685a", + "version" : "1.6.0" + } + }, + { + "identity" : "swift-configuration", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-configuration.git", + "state" : { + "revision" : "be76c4ad929eb6c4bcaf3351799f2adf9e6848a9", + "version" : "1.2.0" } }, { @@ -177,8 +222,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-crypto.git", "state" : { - "revision" : "a6ce32a18b81b04ce7e897d1d98df6eb2da04786", - "version" : "3.12.2" + "revision" : "47d3869a7291f085c1fb9fb1e6d3b97a793f45c6", + "version" : "4.5.1" } }, { @@ -186,8 +231,26 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-distributed-tracing.git", "state" : { - "revision" : "a64a0abc2530f767af15dd88dda7f64d5f1ff9de", - "version" : "1.2.0" + "revision" : "dc4030184203ffafbb2ec614352487235d747fe0", + "version" : "1.4.1" + } + }, + { + "identity" : "swift-docc-plugin", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-docc-plugin", + "state" : { + "revision" : "647c708be89f834fa6a6d4945442793a77ddf5b6", + "version" : "1.5.0" + } + }, + { + "identity" : "swift-docc-symbolkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-docc-symbolkit", + "state" : { + "revision" : "b45d1f2ed151d057b54504d653e0da5552844e34", + "version" : "1.0.0" } }, { @@ -195,8 +258,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-http-structured-headers.git", "state" : { - "revision" : "d01361d32e14ae9b70ea5bd308a3794a198a2706", - "version" : "1.2.0" + "revision" : "933538faa42c432d385f02e07df0ace7c5ecfc47", + "version" : "1.7.0" } }, { @@ -204,8 +267,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-http-types.git", "state" : { - "revision" : "ef18d829e8b92d731ad27bb81583edd2094d1ce3", - "version" : "1.3.1" + "revision" : "db774a277f60063a32d854f2980299caf06da041", + "version" : "1.6.0" } }, { @@ -213,8 +276,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-log.git", "state" : { - "revision" : "3d8596ed08bd13520157f0355e35caed215ffbfa", - "version" : "1.6.3" + "revision" : "a878e7f8f46cfc0e1125e565b5c08e7d5272dc9a", + "version" : "1.14.0" } }, { @@ -222,8 +285,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-metrics.git", "state" : { - "revision" : "44491db7cc66774ab930cf15f36324e16b06f425", - "version" : "2.6.1" + "revision" : "087e8074afa97040c3b870c8664fe5482fb87cc4", + "version" : "2.11.0" } }, { @@ -231,8 +294,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio.git", "state" : { - "revision" : "c51907a839e63ebf0ba2076bba73dd96436bd1b9", - "version" : "2.81.0" + "revision" : "0b18836bd8b0162e7e17a995a3fbee20ed8f3b2b", + "version" : "2.101.3" } }, { @@ -240,8 +303,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio-extras.git", "state" : { - "revision" : "00f3f72d2f9942d0e2dc96057ab50a37ced150d4", - "version" : "1.25.0" + "revision" : "88a51340f59cf181ebde888bd1b749296b3ec029", + "version" : "1.34.3" } }, { @@ -249,8 +312,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio-http2.git", "state" : { - "revision" : "170f4ca06b6a9c57b811293cebcb96e81b661310", - "version" : "1.35.0" + "revision" : "61d1b44f6e4e118792be1cff88ee2bc0267c6f9a", + "version" : "1.44.0" } }, { @@ -258,8 +321,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio-ssl.git", "state" : { - "revision" : "0cc3528ff48129d64ab9cab0b1cd621634edfc6b", - "version" : "2.29.3" + "revision" : "d930168b86f46ca51a4bc09c5ca45c1833db8067", + "version" : "2.37.2" } }, { @@ -267,8 +330,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio-transport-services.git", "state" : { - "revision" : "3c394067c08d1225ba8442e9cffb520ded417b64", - "version" : "1.23.1" + "revision" : "67787bb645a5e67d2edcdfbe48a216cc549222d5", + "version" : "1.28.0" } }, { @@ -276,8 +339,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-numerics.git", "state" : { - "revision" : "e0ec0f5f3af6f3e4d5e7a19d2af26b481acb6ba8", - "version" : "1.0.3" + "revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2", + "version" : "1.1.1" } }, { @@ -285,8 +348,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-service-context.git", "state" : { - "revision" : "8946c930cae601452149e45d31d8ddfac973c3c7", - "version" : "1.2.0" + "revision" : "d0997351b0c7779017f88e7a93bc30a1878d7f29", + "version" : "1.3.0" } }, { @@ -294,8 +357,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swift-server/swift-service-lifecycle.git", "state" : { - "revision" : "7ee57f99fbe0073c3700997186721e74d925b59b", - "version" : "2.7.0" + "revision" : "9829955b385e5bb88128b73f1b8389e9b9c3191a", + "version" : "2.11.0" } }, { @@ -303,8 +366,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-system.git", "state" : { - "revision" : "a34201439c74b53f0fd71ef11741af7e7caf01e1", - "version" : "1.4.2" + "revision" : "b5544ba79a70a0cb3563e75bf26dc198d6b40ed3", + "version" : "1.7.4" } }, { @@ -321,8 +384,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/vapor/vapor.git", "state" : { - "revision" : "87b0edd2633c35de543cb7573efe5fbf456181bc", - "version" : "4.114.1" + "revision" : "748ae8432a33e0965bbf0351fedd4e915e7f460c", + "version" : "4.122.0" } }, { @@ -330,8 +393,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/vapor-community/vapor-queues-fluent-driver.git", "state" : { - "revision" : "8effcf143226e16746d2b16cf8c81f16ad931fd2", - "version" : "3.1.0" + "revision" : "a77e1105c4bee9f5edd87321a3025bbf0b0541ad", + "version" : "3.2.0" } }, { @@ -339,8 +402,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/vapor/websocket-kit.git", "state" : { - "revision" : "4232d34efa49f633ba61afde365d3896fc7f8740", - "version" : "2.15.0" + "revision" : "90bbbdab3ede12c803cfbe91646f291c092517a3", + "version" : "2.16.2" } }, { @@ -348,8 +411,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/CoreOffice/XMLCoder.git", "state" : { - "revision" : "b1e944cbd0ef33787b13f639a5418d55b3bed501", - "version" : "0.17.1" + "revision" : "42f62383dbcd074440cb1f6a750b9c02df9e7325", + "version" : "0.18.2" } } ], diff --git a/Package.swift b/Package.swift index 72a9b12..2aad035 100644 --- a/Package.swift +++ b/Package.swift @@ -1,22 +1,22 @@ -// swift-tools-version:6.0 +// swift-tools-version:6.3 import PackageDescription let package = Package( name: "mul-api", platforms: [ - .macOS(.v14) + .macOS(.v26) ], dependencies: [ - .package(url: "https://github.com/vapor/vapor.git", from: "4.114.0"), - .package(url: "https://github.com/vapor/fluent.git", from: "4.12.0"), - .package(url: "https://github.com/vapor/fluent-postgres-driver.git", from: "2.10.0"), - .package(url: "https://github.com/vapor/leaf.git", from: "4.4.1"), - .package(url: "https://github.com/CoreOffice/XMLCoder.git", from: "0.17.1"), + .package(url: "https://github.com/vapor/vapor.git", from: "4.122.0"), + .package(url: "https://github.com/vapor/fluent.git", from: "4.13.0"), + .package(url: "https://github.com/vapor/fluent-postgres-driver.git", from: "2.12.0"), + .package(url: "https://github.com/vapor/leaf.git", from: "4.5.2"), + .package(url: "https://github.com/CoreOffice/XMLCoder.git", from: "0.18.2"), .package(url: "https://github.com/swiftcsv/SwiftCSV.git", from: "0.10.0"), - .package(url: "https://github.com/apple/swift-nio.git", from: "2.81.0"), - .package( - url: "https://github.com/vapor-community/vapor-queues-fluent-driver.git", from: "3.0.0-beta.4" - ), + .package(url: "https://github.com/apple/swift-nio.git", from: "2.101.3"), + .package(url: "https://github.com/vapor-community/sendgrid.git", from: "6.0.0"), + .package(url: "https://github.com/vapor-community/vapor-queues-fluent-driver.git", from: "3.2.0"), + .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.3.0"), ], targets: [ .executableTarget( @@ -28,6 +28,7 @@ let package = Package( .product(name: "Vapor", package: "vapor"), .product(name: "XMLCoder", package: "XMLCoder"), .product(name: "SwiftCSV", package: "SwiftCSV"), + .product(name: "SendGrid", package: "sendgrid"), .product(name: "NIOCore", package: "swift-nio"), .product(name: "NIOPosix", package: "swift-nio"), .product(name: "QueuesFluentDriver", package: "vapor-queues-fluent-driver"), @@ -37,7 +38,8 @@ let package = Package( name: "AppTests", dependencies: [ .target(name: "App"), - .product(name: "XCTVapor", package: "vapor"), + .product(name: "VaporTesting", package: "vapor"), + .product(name: "_NIOFileSystem", package: "swift-nio"), ]), ] ) diff --git a/README.md b/README.md index 53331ae..a62c015 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,52 @@ -# BattleTech API +# BattleTech API (mul-api) -A public access API for access to various bits of BattleTech data -sourced from MegaMek data. +A public, read-mostly REST API for BattleTech tabletop-game reference data +(weapons, equipment, ammunition, factions, eras, rules, tech bases, and tech +levels), sourced from the [MegaMek](https://github.com/MegaMek) project's +data. It also hosts a small "server announce" registry that running MegaMek +game servers use to advertise themselves to players, and an internal +account system (registration, email confirmation, password reset) backing +authenticated write operations. -## Dev Database +Built with [Vapor](https://vapor.codes) (server-side Swift) and +[Fluent](https://docs.vapor.codes/fluent/overview/) on PostgreSQL. -For local development, a docker version of Postgres should -be used to allow for the most portability. One can be created -with the following command: +- Production: +- Staging: + +## What this API provides + +- **`/battletech/*`** — read-only, paginated access to `ammo`, `equipment`, + `era`, `faction`, `munitiontype`, `rules`, `techbase`, `techlevel`, and + `weapon` reference data. Each resource also exposes a CSV mass-import + endpoint (`POST /battletech//import`) that queues rows as + background jobs (see `Sources/App/Jobs`) rather than importing inline. +- **`/servers`** and **`/servers/announce`** — the original endpoints a + running MegaMek game server calls to list itself (and its player count, + password state, version, etc.) in the public server directory. +- **`/api/v1/servers`** — the versioned successor to the endpoints above; + the API is mid-transition from the unversioned `/servers` routes to this + `/api/v1` structure (see `Sources/App/Controllers/ServersController.swift`). +- **Accounts** — `Security.User` and related token models support email + confirmation and forgot-password flows (delivered via SendGrid) for + future authenticated endpoints. + +## Requirements + +- **Swift 6.3** toolchain, matching `Package.swift` and the `Dockerfile`. + Install via [swift.org](https://www.swift.org/install/) or + [Swiftly](https://www.swift.org/swiftly/), or use the `swift:6.3-noble` + Docker image if you don't want to install Swift locally. +- **PostgreSQL** (developed against `postgres:latest` / `postgres:18-alpine`). +- **Docker** and **Docker Compose**, optional, for a containerized + dev/prod-like setup. +- A **SendGrid** account and API key — optional for local development + (account emails will just fail to send without it), required in any + environment that needs to actually deliver confirmation/reset emails. + +## Local development + +### 1. Start a database ```sh docker run --name api-battletech \ @@ -17,7 +56,7 @@ docker run --name api-battletech \ -p 5432:5432 -d postgres:latest ``` -To run: +### 2. Build, migrate, and run ```sh swift build @@ -25,9 +64,29 @@ swift run App migrate -y swift run App serve ``` -## Testing Database +With no environment variables set, the app connects to Postgres at +`localhost:5432` using the `vapor` / `password` / `api-battletech` +credentials above — see [Configuration](#configuration) for the full list +of variables and their defaults. -For testing, use the following to load a docker instance: +### Running with Docker Compose (alternative) + +`docker-compose.yml` bundles the app, a Postgres database, and one-off +migration/revert runners: + +```sh +docker-compose build # build the app image +docker-compose up db # start just the database +docker-compose up app # start the app (depends on db) +docker-compose run migrate # run pending migrations +docker-compose run revert # revert the last migration batch +docker-compose down # stop everything (add -v to also wipe the db volume) +``` + +## Testing + +Use a second, disposable Postgres instance on a different port so it can +run alongside your dev database: ```sh docker run --name api-battletech-test \ @@ -37,8 +96,70 @@ docker run --name api-battletech-test \ -p 5433:5432 -d postgres:latest ``` -We use a different port to allow both to run at the same time. - ```sh swift test --enable-code-coverage ``` + +CI (`.github/workflows/ci.yml`) runs the same command and additionally +publishes a coverage summary to the workflow run's job summary, plus an +`lcov` report as a build artifact. To see a coverage summary locally, run +the same `llvm-cov` step CI uses: + +```sh +BIN_PATH="$(swift build --show-bin-path)" +XCTEST_PATH="$(find "$BIN_PATH" -name '*.xctest')" +llvm-cov report "$XCTEST_PATH" \ + --instr-profile=".build/debug/codecov/default.profdata" \ + --ignore-filename-regex="(\.build|TestUtils|Tests)" +``` + +## Documentation + +Public types and methods are documented with Swift-DocC-compatible `///` +comments throughout `Sources/App`. Generate browsable API documentation +locally with: + +```sh +swift package generate-documentation --target App +``` + +or, in Xcode, **Product ▸ Build Documentation**. On every push to `develop`, +`.github/workflows/docs.yml` regenerates this documentation and publishes it +to GitHub Pages (requires the repo's **Settings ▸ Pages ▸ Source** to be set +to **GitHub Actions**, once, by a repo admin). + +## Configuration + +Runtime configuration is read entirely from environment variables (see +`Sources/App/Settings/Database.swift`): + +| Variable | Purpose | Default | +| - | - | - | +| `DATABASE_HOST` | Postgres hostname | `localhost` | +| `DATABASE_PORT` | Postgres port | `5432` | +| `DATABASE_USERNAME` | Postgres username | `vapor` | +| `DATABASE_PASSWORD` | Postgres password | `password` | +| `DATABASE_NAME` | Postgres database name | `api-battletech` | +| `SENDGRID_API_KEY` | SendGrid API key used to send confirmation/password-reset emails | *(none — email sending fails without it)* | +| `LOG_LEVEL` | Vapor log verbosity (used by `docker-compose.yml`) | `debug` | + +## Deployment + +Deploys run via GitHub Actions (`.github/workflows/ci.yml` and +`deploy.yml`): pushing to `develop` builds and pushes a container image to +`ghcr.io/megamek/api-data` and automatically deploys it to staging; pushing +to `main` builds the production image, but the production deploy itself is +a manual `workflow_dispatch` gated to the `main` branch. Both deploy jobs +run an Ansible playbook (`ansible/staging.yml` / `ansible/production.yml`) +over SSH against the target host to pull the new image and restart the +service. + +Required GitHub Actions secrets for deployment: + +| Secret | Used for | +| - | - | +| `DEPLOY_SSH_PRIVATE_KEY` | SSH key for the `deploy` user on the staging/production hosts | +| `GHCR_READ_TOKEN` | Lets the deploy hosts `docker pull` from `ghcr.io` | +| `STAGING_DATABASE_HOST`, `_NAME`, `_USERNAME`, `_PASSWORD` | Postgres connection info for the staging container | +| `PRODUCTION_DATABASE_HOST`, `_NAME`, `_USERNAME`, `_PASSWORD` | Postgres connection info for the production container | +| `SENDGRID_API_KEY` | Passed through to both environments' containers | diff --git a/Sources/App/Common/EmailHandler.swift b/Sources/App/Common/EmailHandler.swift new file mode 100644 index 0000000..5c38d4f --- /dev/null +++ b/Sources/App/Common/EmailHandler.swift @@ -0,0 +1,110 @@ +// +// EmailHandler.swift +// mul-api +// +// Created by Richard Hancock on 5/9/25. +// + +import Fluent +import SendGrid +import Vapor + +/// Helper for rendering Leaf templates into email bodies and queuing them for +/// delivery via ``SendEmailJob``, rather than sending synchronously on the +/// request thread. Used by the user-auth flows (e.g. account confirmation, +/// email-change confirmation). +enum EmailHandler { + /// Renders a Leaf template as plain-text and queues it for delivery to a + /// user's confirmed email address. + /// + /// - Parameters: + /// - to: The `Security.User` to email; uses `to.email`. + /// - subject: The email subject line. + /// - body: The Leaf template name/path to render as the email body. + /// - context: The `Encodable` context passed to the Leaf renderer. + /// - req: The current `Request`, used to access Leaf rendering and the job queue. + /// - Throws: Rethrows errors from Leaf rendering or job dispatch. + static func sendEmail( + _ to: Security.User, + subject: String, + body: String, + context: any Encodable, + on req: Request + ) async throws { + let emailContentView: View = try await req.leaf.render(body, context).get() + + let toEmail = EmailAddress( + email: to.email, + name: to.name + ) + + let emailConfig = Personalization( + to: [toEmail], + subject: subject + ) + + let emailContent = EmailContent( + type: "text/plain", + value: String(buffer: emailContentView.data) + ) + + try await EmailHandler.send(emailConfig: emailConfig, emailContent: emailContent, on: req) + } + + /// Renders a Leaf template as plain-text and queues it for delivery to a + /// user's pending (not-yet-confirmed) new email address, as part of the + /// email-change confirmation flow. + /// + /// - Parameters: + /// - to: The `Security.User` whose `unconfirmedEmail` should be messaged. + /// - subject: The email subject line. + /// - body: The Leaf template name/path to render as the email body. + /// - context: The `Encodable` context passed to the Leaf renderer. + /// - req: The current `Request`, used to access Leaf rendering and the job queue. + /// - Throws: `Security.UserError.cantEmailUnconfirmedWithoutEmailAddress` if the + /// user has no pending unconfirmed email address; otherwise rethrows errors + /// from Leaf rendering or job dispatch. + static func sendEmailChange( + _ to: Security.User, + subject: String, + body: String, + context: any Encodable, + on req: Request + ) async throws { + guard let unconfirmedEmail = to.unconfirmedEmail else { + throw Security.UserError.cantEmailUnconfirmedWithoutEmailAddress + } + + let emailContentView: View = try await req.leaf.render(body, context).get() + + let toEmail = EmailAddress( + email: unconfirmedEmail, + name: to.name + ) + + let emailConfig = Personalization( + to: [toEmail], + subject: subject + ) + + let emailContent = EmailContent( + type: "text/plain", + value: String(buffer: emailContentView.data) + ) + + try await EmailHandler.send(emailConfig: emailConfig, emailContent: emailContent, on: req) + } + + /// Wraps a prepared personalization and content into a ``SendEMailPayload`` + /// and dispatches it to the queue so ``SendEmailJob`` can send it via SendGrid. + /// + /// - Parameters: + /// - emailConfig: The recipient(s) and subject for the message. + /// - emailContent: The rendered body content and MIME type. + /// - req: The current `Request`, used to access the job queue. + /// - Throws: Rethrows errors from job dispatch. + static func send(emailConfig: Personalization<[String: String]>, emailContent: EmailContent, on req: Request) async throws { + let payload = SendEMailPayload(personalizations: [emailConfig], content: [emailContent]) + try await req.queue.dispatch(SendEmailJob.self, payload) + } +} diff --git a/Sources/App/Controllers/Api/APIV1Controller.swift b/Sources/App/Controllers/Api/APIV1Controller.swift new file mode 100644 index 0000000..c5e5112 --- /dev/null +++ b/Sources/App/Controllers/Api/APIV1Controller.swift @@ -0,0 +1,33 @@ +// +// APIV1Controller.swift +// mul-api +// +// Created by Richard Hancock on 5/9/25. +// + +/// Root of the `/api/v1` route namespace, mounted by ``Api/RootController`` under +/// `/api`. Groups together the version-1 route collections; currently just the +/// servers-announce endpoints. +import Vapor + +extension Api { + /// Empty namespace type for version-1 API types (e.g. ``Api/V1/ServersController``), + /// grouped under `Api.V1` purely to keep versioned types organized. + struct V1 {} +} + +extension Api.V1 { + /// A Vapor `RouteCollection` for everything under `/api/v1`. + struct RootController: RouteCollection { + /// Groups all routes under the `/api/v1` path prefix and mounts the + /// servers route collection beneath it, producing paths like + /// `/api/v1/servers`. + /// + /// - Parameter routes: The `RoutesBuilder` to register routes on. + /// - Throws: Rethrows any error from registering the servers collection. + func boot(routes: any RoutesBuilder) throws { + let v1 = routes.grouped("v1") + try v1.register(collection: Api.V1.ServersController()) + } + } +} diff --git a/Sources/App/Controllers/Api/V1/APIV1ServersController.swift b/Sources/App/Controllers/Api/V1/APIV1ServersController.swift new file mode 100644 index 0000000..01c2e97 --- /dev/null +++ b/Sources/App/Controllers/Api/V1/APIV1ServersController.swift @@ -0,0 +1,79 @@ +// +// APIV1ServersController.swift +// mul-api +// +// Created by Richard Hancock on 5/9/25. +// + +/// Versioned `/api/v1/servers` route collection: the planned replacement for the +/// legacy top-level `/servers` announce endpoints in ``ServersController``. Registered +/// under `/api/v1` by ``Api/V1/RootController``. +import Vapor + +extension Api.V1 { + /// A Vapor `RouteCollection` for the `/api/v1/servers` endpoints. + /// + /// - Note: The `create`, `update`, and `destroy` handlers are currently stubs + /// that always return `202 Accepted` without reading the request body or + /// touching the database — this collection is a scaffold for the API + /// migration in progress and is not yet functionally equivalent to the + /// legacy ``ServersController``. + struct ServersController: RouteCollection { + /// Groups routes under `/servers` (i.e. `/api/v1/servers`): + /// `GET /` (list), `POST /` (create), `PUT`/`PATCH /:server_id` (update), + /// and `DELETE /:server_id` (destroy). + /// + /// - Parameter routes: The `RoutesBuilder` to register routes on (already + /// scoped to `/api/v1` by the caller). + /// - Throws: Does not currently throw; matches Vapor's `boot(routes:)` + /// signature. + func boot(routes: any RoutesBuilder) throws { + let servers = routes.grouped("servers") + servers.get(use: index).description("Get A List Of Servers") + servers.post(use: create).description("Add A Server To The List") + servers.put(":server_id", use: update).description("Update an existing server") + servers.patch(":server_id", use: update).description("Update an existing server") + servers.delete(":server_id", use: destroy).description("Delete an existing server.") + } + + /// Serves `GET /api/v1/servers`. + /// + /// - Parameter req: The incoming `Request`. + /// - Returns: Every ``MegaMek/Server`` row currently in the database, as + /// JSON (unpaginated). + /// - Throws: Rethrows errors from the database query. + func index(req: Request) async throws -> [MegaMek.Server] { + return try await MegaMek.Server.query(on: req.db).all() + } + + /// Serves `POST /api/v1/servers`. + /// + /// - Parameter req: The incoming `Request` (currently unused). + /// - Returns: A `202 Accepted` response. This is a placeholder — no server + /// is actually created yet. + /// - Throws: Does not currently throw. + func create(req: Request) async throws -> Response { + return .init(status: .accepted) + } + + /// Serves `PUT`/`PATCH /api/v1/servers/:server_id`. + /// + /// - Parameter req: The incoming `Request` (currently unused). + /// - Returns: A `202 Accepted` response. This is a placeholder — no server + /// is actually updated yet. + /// - Throws: Does not currently throw. + func update(req: Request) async throws -> Response { + return .init(status: .accepted) + } + + /// Serves `DELETE /api/v1/servers/:server_id`. + /// + /// - Parameter req: The incoming `Request` (currently unused). + /// - Returns: A `202 Accepted` response. This is a placeholder — no server + /// is actually deleted yet. + /// - Throws: Does not currently throw. + func destroy(req: Request) async throws -> Response { + return .init(status: .accepted) + } + } +} diff --git a/Sources/App/Controllers/ApiController.swift b/Sources/App/Controllers/ApiController.swift new file mode 100644 index 0000000..fb9144c --- /dev/null +++ b/Sources/App/Controllers/ApiController.swift @@ -0,0 +1,35 @@ +// +// ApiController.swift +// mul-api +// +// Created by Richard Hancock on 5/9/25. +// + +/// Root of the versioned `/api` route namespace. Registered from `routes.swift`, +/// this mounts the `/api/v1/...` surface used for the newer, versioned parts of +/// the API (currently the servers-announce endpoints), as opposed to the +/// unversioned `/battletech/...` data endpoints. +import Vapor + +/// Empty namespace type. `Api.V1`, `Api.RootController`, etc. are declared as +/// nested types in extensions of ``Api``, purely to group the versioned-API types +/// under one name. +struct Api {} + +extension Api { + /// A Vapor `RouteCollection` — a type that groups a set of related HTTP routes + /// and registers them on the app's router via `boot(routes:)`. This is the + /// entry point for everything under `/api`. + struct RootController: RouteCollection { + /// Groups all routes under the `/api` path prefix and mounts the `v1` + /// route collection beneath it, producing paths like `/api/v1/servers`. + /// + /// - Parameter routes: The `RoutesBuilder` (the app's router, or a group + /// of it) to register routes on. + /// - Throws: Rethrows any error from registering the `v1` collection. + func boot(routes: any RoutesBuilder) throws { + let api = routes.grouped("api") + try api.register(collection: Api.V1.RootController()) + } + } +} diff --git a/Sources/App/Controllers/BattleTech/AmmoController.swift b/Sources/App/Controllers/BattleTech/AmmoController.swift index 5431d96..71f7db4 100644 --- a/Sources/App/Controllers/BattleTech/AmmoController.swift +++ b/Sources/App/Controllers/BattleTech/AmmoController.swift @@ -1,9 +1,20 @@ +/// Read-only public API for BattleTech ammo data, plus an admin CSV-upload +/// endpoint for (re)populating it. Registered under `/battletech/ammo` by +/// ``BattleTech/RootController``. import Fluent import SwiftCSV import Vapor extension BattleTech { + /// A Vapor `RouteCollection` for `/battletech/ammo`. struct AmmoController: RouteCollection { + /// Groups routes under `/ammo`: `GET /` (paginated list), + /// `POST /import` (mass CSV import), and `GET`/`DELETE /:ammo_id` + /// (single-item show/delete). + /// + /// - Parameter routes: The `RoutesBuilder` to register routes on. + /// - Throws: Does not currently throw; matches Vapor's `boot(routes:)` + /// signature. func boot(routes: RoutesBuilder) throws { let ammo = routes.grouped("ammo") ammo.get(use: index).description("All Ammo") @@ -14,6 +25,15 @@ extension BattleTech { } } + /// Serves `GET /battletech/ammo`. + /// + /// - Parameter req: The incoming `Request`; standard Fluent pagination + /// query params (`page`, `per`) are honored via `paginate(for:)`. + /// - Returns: A `Page` of ``BattleTech/Ammo`` rows, with their tech base, + /// applicable rules, static tech level, munition type, and aliases + /// eagerly loaded (via Fluent's `Model.query(on:)` + `.with(_:)`, so + /// related rows are fetched up front instead of lazily per-item). + /// - Throws: Rethrows errors from the database query. func index(req: Request) async throws -> Page { try await BattleTech.Ammo.query(on: req.db) .with(\.$techBase) @@ -24,10 +44,26 @@ extension BattleTech { .paginate(for: req) } + /// Serves `GET /battletech/ammo/:ammo_id`. + /// + /// - Parameter req: The incoming `Request`; must include a valid + /// `ammo_id` UUID path parameter (read via `req.parameters`). + /// - Returns: The matching ``BattleTech/Ammo`` row with its relations + /// eagerly loaded. + /// - Throws: `Abort(.notFound)` if the id is missing, not a UUID, or + /// doesn't match any row. func show(req: Request) async throws -> BattleTech.Ammo { return try await ammoForRequest(req: req) } + /// Serves `DELETE /battletech/ammo/:ammo_id`. + /// + /// - Parameter req: The incoming `Request`; must include a valid + /// `ammo_id` UUID path parameter. + /// - Returns: `204 No Content` on success. + /// - Throws: `Abort(.notFound)` if the ammo doesn't exist; rethrows + /// database errors otherwise. Detaches the ammo's rules pivot rows + /// before deleting so the many-to-many join table doesn't dangle. func delete(req: Request) async throws -> HTTPStatus { let ammo = try await ammoForRequest(req: req) try await ammo.$rules.detachAll(on: req.db) @@ -35,6 +71,20 @@ extension BattleTech { return .noContent } + /// Serves `POST /battletech/ammo/import`. An admin-facing bulk-load + /// endpoint: decodes an uploaded ammo CSV file (`req.content` reads the + /// multipart body into an ``AmmoMassImport``), parses it row by row with + /// ``Importers/AmmoCSVRow``, skips rows marked "Unofficial" in their + /// rules/tech-level columns, and dispatches the rest as ``AmmoImportJob`` + /// background jobs (each retried up to 5 times) rather than writing them + /// to the database inline. + /// + /// - Parameter req: The incoming `Request`; the body must be + /// multipart/form-data containing a `file` field with CSV content. + /// - Returns: `201 Created` once every row has been queued (not once + /// every row has actually been imported — importing happens + /// asynchronously). + /// - Throws: Rethrows errors from decoding the upload or dispatching jobs. func massCreate(req: Request) async throws -> HTTPStatus { let input = try req.content.decode(AmmoMassImport.self) let csvString = String(bytes: Data(buffer: input.file.data), encoding: .utf8) ?? "" @@ -55,6 +105,14 @@ extension BattleTech { return .created } + /// Looks up the ``BattleTech/Ammo`` row identified by the `ammo_id` path + /// parameter, with its relations eagerly loaded. + /// + /// - Parameter req: The incoming `Request` supplying the `ammo_id` + /// path parameter. + /// - Returns: The matching ammo row. + /// - Throws: `Abort(.notFound)` if the id is missing, not a UUID, or + /// doesn't match any row. private func ammoForRequest(req: Request) async throws -> BattleTech.Ammo { guard let ammoIdString = req.parameters.get("ammo_id"), let ammoUUID = UUID(ammoIdString), @@ -75,6 +133,8 @@ extension BattleTech { } } +/// The multipart form body expected by `POST /battletech/ammo/import`: a single +/// uploaded file containing the ammo CSV data. struct AmmoMassImport: Content { var file: File } diff --git a/Sources/App/Controllers/BattleTech/EquipmentController.swift b/Sources/App/Controllers/BattleTech/EquipmentController.swift index dee16e8..d8e2439 100644 --- a/Sources/App/Controllers/BattleTech/EquipmentController.swift +++ b/Sources/App/Controllers/BattleTech/EquipmentController.swift @@ -1,9 +1,20 @@ +/// Read-only public API for BattleTech equipment data, plus an admin CSV-upload +/// endpoint for (re)populating it. Registered under `/battletech/equipment` by +/// ``BattleTech/RootController``. import Fluent import SwiftCSV import Vapor extension BattleTech { + /// A Vapor `RouteCollection` for `/battletech/equipment`. struct EquipmentController: RouteCollection { + /// Groups routes under `/equipment`: `GET /` (paginated list), + /// `POST /import` (mass CSV import), and `GET`/`DELETE /:equipment_id` + /// (single-item show/delete). + /// + /// - Parameter routes: The `RoutesBuilder` to register routes on. + /// - Throws: Does not currently throw; matches Vapor's `boot(routes:)` + /// signature. func boot(routes: RoutesBuilder) throws { let equipment = routes.grouped("equipment") equipment.get(use: index).description("All Equipment") @@ -14,6 +25,14 @@ extension BattleTech { } } + /// Serves `GET /battletech/equipment`. + /// + /// - Parameter req: The incoming `Request`; standard Fluent pagination + /// query params (`page`, `per`) are honored via `paginate(for:)`. + /// - Returns: A `Page` of ``BattleTech/Equipment`` rows, with their tech + /// base, applicable rules, static tech level, and aliases eagerly + /// loaded (via Fluent's `Model.query(on:)` + `.with(_:)`). + /// - Throws: Rethrows errors from the database query. func index(req: Request) async throws -> Page { try await BattleTech.Equipment.query(on: req.db) .with(\.$techBase) @@ -23,10 +42,26 @@ extension BattleTech { .paginate(for: req) } + /// Serves `GET /battletech/equipment/:equipment_id`. + /// + /// - Parameter req: The incoming `Request`; must include a valid + /// `equipment_id` UUID path parameter (read via `req.parameters`). + /// - Returns: The matching ``BattleTech/Equipment`` row with its relations + /// eagerly loaded. + /// - Throws: `Abort(.notFound)` if the id is missing, not a UUID, or + /// doesn't match any row. func show(req: Request) async throws -> BattleTech.Equipment { return try await equipmentForReq(req: req) } + /// Serves `DELETE /battletech/equipment/:equipment_id`. + /// + /// - Parameter req: The incoming `Request`; must include a valid + /// `equipment_id` UUID path parameter. + /// - Returns: `204 No Content` on success. + /// - Throws: `Abort(.notFound)` if the equipment doesn't exist; rethrows + /// database errors otherwise. Detaches the equipment's rules pivot rows + /// before deleting so the many-to-many join table doesn't dangle. func delete(req: Request) async throws -> HTTPStatus { let equipment = try await equipmentForReq(req: req) try await equipment.$rules.detachAll(on: req.db) @@ -34,6 +69,20 @@ extension BattleTech { return .noContent } + /// Serves `POST /battletech/equipment/import`. An admin-facing bulk-load + /// endpoint: decodes an uploaded equipment CSV file (`req.content` reads + /// the multipart body into an ``EquipmentMassImport``), parses it row by + /// row with ``Importers/EquipmentCSVRow``, skips rows marked "Unofficial" + /// in their rules/tech-level columns, and dispatches the rest as + /// ``EquipmentImportJob`` background jobs (each retried up to 5 times) + /// rather than writing them to the database inline. + /// + /// - Parameter req: The incoming `Request`; the body must be + /// multipart/form-data containing a `file` field with CSV content. + /// - Returns: `201 Created` once every row has been queued (not once + /// every row has actually been imported — importing happens + /// asynchronously). + /// - Throws: Rethrows errors from decoding the upload or dispatching jobs. func massCreate(req: Request) async throws -> HTTPStatus { let input = try req.content.decode(EquipmentMassImport.self) let csvString = String(bytes: Data(buffer: input.file.data), encoding: .utf8) ?? "" @@ -54,6 +103,14 @@ extension BattleTech { return .created } + /// Looks up the ``BattleTech/Equipment`` row identified by the + /// `equipment_id` path parameter, with its relations eagerly loaded. + /// + /// - Parameter req: The incoming `Request` supplying the `equipment_id` + /// path parameter. + /// - Returns: The matching equipment row. + /// - Throws: `Abort(.notFound)` if the id is missing, not a UUID, or + /// doesn't match any row. private func equipmentForReq(req: Request) async throws -> BattleTech.Equipment { guard let equipmentIdString = req.parameters.get("equipment_id"), let equipmentUUID = UUID(equipmentIdString), @@ -73,6 +130,8 @@ extension BattleTech { } } +/// The multipart form body expected by `POST /battletech/equipment/import`: a +/// single uploaded file containing the equipment CSV data. struct EquipmentMassImport: Content { var file: File } diff --git a/Sources/App/Controllers/BattleTech/EraController.swift b/Sources/App/Controllers/BattleTech/EraController.swift index e6b9115..6cb83b4 100644 --- a/Sources/App/Controllers/BattleTech/EraController.swift +++ b/Sources/App/Controllers/BattleTech/EraController.swift @@ -1,9 +1,19 @@ +/// Read-only public API for BattleTech historical eras, plus an admin XML-upload +/// endpoint for (re)populating them. Registered under `/battletech/eras` by +/// ``BattleTech/RootController``. import Fluent import Vapor import XMLCoder extension BattleTech { + /// A Vapor `RouteCollection` for `/battletech/eras`. struct EraController: RouteCollection { + /// Groups routes under `/eras`: `GET /` (list), `POST /import` (mass XML + /// import), and `GET`/`DELETE /:era_id` (single-item show/delete). + /// + /// - Parameter routes: The `RoutesBuilder` to register routes on. + /// - Throws: Does not currently throw; matches Vapor's `boot(routes:)` + /// signature. func boot(routes: RoutesBuilder) throws { let eras = routes.grouped("eras") eras.get(use: index).description("All Eras") @@ -14,22 +24,56 @@ extension BattleTech { } } + /// Serves `GET /battletech/eras`. + /// + /// - Parameter req: The incoming `Request`. + /// - Returns: Every ``BattleTech/Era`` row, sorted chronologically by + /// `startYear` (unpaginated, since there are only a handful of eras). + /// - Throws: Rethrows errors from the database query. func index(req: Request) async throws -> [BattleTech.Era] { try await BattleTech.Era.query(on: req.db) .sort(\.$startYear) .all() } + /// Serves `GET /battletech/eras/:era_id`. + /// + /// - Parameter req: The incoming `Request`; must include a valid + /// `era_id` UUID path parameter (read via `req.parameters`). + /// - Returns: The matching ``BattleTech/Era`` row. + /// - Throws: `Abort(.notFound)` if the id is missing, not a UUID, or + /// doesn't match any row. func show(req: Request) async throws -> BattleTech.Era { return try await eraForReq(req: req) } + /// Serves `DELETE /battletech/eras/:era_id`. + /// + /// - Parameter req: The incoming `Request`; must include a valid + /// `era_id` UUID path parameter. + /// - Returns: `204 No Content` on success. + /// - Throws: `Abort(.notFound)` if the era doesn't exist; rethrows + /// database errors otherwise. func delete(req: Request) async throws -> HTTPStatus { let era = try await eraForReq(req: req) try await era.delete(on: req.db) return .noContent } + /// Serves `POST /battletech/eras/import`. An admin-facing bulk-load + /// endpoint: decodes an uploaded era XML file (`req.content` reads the + /// multipart body into an ``EraMassImport``) with `XMLDecoder` into + /// ``Importers/Eras``, sorts the parsed eras by end year, then — unlike + /// the CSV importers — processes them synchronously (no background job): + /// each era's `startYear` is computed as one year after the previous + /// era's end year, and ``BattleTech/Era/findOrNew(importableEra:startYear:on:)`` + /// upserts the corresponding database row. + /// + /// - Parameter req: The incoming `Request`; the body must be + /// multipart/form-data containing a `file` field with era XML content. + /// - Returns: `201 Created` once every era has been imported. + /// - Throws: Rethrows errors from decoding the upload/XML or from the + /// database upserts. func massCreate(req: Request) async throws -> HTTPStatus { let input = try req.content.decode(EraMassImport.self) let decoder = XMLDecoder() @@ -51,6 +95,14 @@ extension BattleTech { return .created } + /// Looks up the ``BattleTech/Era`` row identified by the `era_id` path + /// parameter. + /// + /// - Parameter req: The incoming `Request` supplying the `era_id` path + /// parameter. + /// - Returns: The matching era row. + /// - Throws: `Abort(.notFound)` if the id is missing, not a UUID, or + /// doesn't match any row. private func eraForReq(req: Request) async throws -> BattleTech.Era { guard let eraIdString = req.parameters.get("era_id"), let eraUUID = UUID(eraIdString), @@ -66,6 +118,8 @@ extension BattleTech { } } +/// The multipart form body expected by `POST /battletech/eras/import`: a single +/// uploaded file containing the era XML data. struct EraMassImport: Content { var file: File } diff --git a/Sources/App/Controllers/BattleTech/FactionController.swift b/Sources/App/Controllers/BattleTech/FactionController.swift index dbcfc2a..3900b70 100644 --- a/Sources/App/Controllers/BattleTech/FactionController.swift +++ b/Sources/App/Controllers/BattleTech/FactionController.swift @@ -1,9 +1,20 @@ +/// Read-only public API for BattleTech factions, plus an admin XML-upload +/// endpoint for (re)populating them. Registered under `/battletech/factions` by +/// ``BattleTech/RootController``. import Fluent import Vapor import XMLCoder extension BattleTech { + /// A Vapor `RouteCollection` for `/battletech/factions`. struct FactionController: RouteCollection { + /// Groups routes under `/factions`: `GET /` (list), `POST /import` (mass + /// XML import), and `GET`/`DELETE /:faction_id` (single-item + /// show/delete). + /// + /// - Parameter routes: The `RoutesBuilder` to register routes on. + /// - Throws: Does not currently throw; matches Vapor's `boot(routes:)` + /// signature. func boot(routes: RoutesBuilder) throws { let factions = routes.grouped("factions") factions.get(use: index).description("All Factions") @@ -14,6 +25,12 @@ extension BattleTech { } } + /// Serves `GET /battletech/factions`. + /// + /// - Parameter req: The incoming `Request`. + /// - Returns: Every ``BattleTech/Faction`` row (unpaginated), with its + /// historical names, parent factions, and subfactions eagerly loaded. + /// - Throws: Rethrows errors from the database query. func index(req: Request) async throws -> [BattleTech.Faction] { try await BattleTech.Faction.query(on: req.db) .with(\.$names) @@ -22,16 +39,51 @@ extension BattleTech { .all() } + /// Serves `GET /battletech/factions/:faction_id`. + /// + /// - Parameter req: The incoming `Request`; must include a valid + /// `faction_id` UUID path parameter (read via `req.parameters`). + /// - Returns: The matching ``BattleTech/Faction`` row with its relations + /// eagerly loaded. + /// - Throws: `Abort(.notFound)` if the id is missing, not a UUID, or + /// doesn't match any row. func show(req: Request) async throws -> BattleTech.Faction { return try await factionForReq(req: req) } + /// Serves `DELETE /battletech/factions/:faction_id`. + /// + /// - Parameter req: The incoming `Request`; must include a valid + /// `faction_id` UUID path parameter. + /// - Returns: `204 No Content` on success. + /// - Throws: `Abort(.notFound)` if the faction doesn't exist; rethrows + /// database errors otherwise. func delete(req: Request) async throws -> HTTPStatus { let faction = try await factionForReq(req: req) try await faction.delete(on: req.db) return .noContent } + /// Serves `POST /battletech/factions/import`. An admin-facing bulk-load + /// endpoint: decodes an uploaded faction XML file (`req.content` reads + /// the multipart body into a ``FactionMassImport``) with `XMLDecoder` + /// into ``Importers/Factions``, then processes it synchronously (no + /// background job) in two passes: first every faction is + /// created/updated and its name history rebuilt via + /// ``BattleTech/Faction/findOrNew(importableFaction:on:)`` and + /// ``BattleTech/Faction/updateName(importableFaction:on:)``; only once + /// all factions exist does the second pass link parent/subfaction + /// relationships via ``BattleTech/Faction/updateParent(importableFaction:on:)``, + /// since a parent faction must already be in the database before it can + /// be attached. + /// + /// - Parameter req: The incoming `Request`; the body must be + /// multipart/form-data containing a `file` field with faction XML + /// content. + /// - Returns: `201 Created` once every faction has been imported and + /// linked. + /// - Throws: Rethrows errors from decoding the upload/XML or from the + /// database upserts. func massCreate(req: Request) async throws -> HTTPStatus { let input = try req.content.decode(FactionMassImport.self) let decoder = XMLDecoder() @@ -61,6 +113,14 @@ extension BattleTech { return .created } + /// Looks up the ``BattleTech/Faction`` row identified by the + /// `faction_id` path parameter, with its relations eagerly loaded. + /// + /// - Parameter req: The incoming `Request` supplying the `faction_id` + /// path parameter. + /// - Returns: The matching faction row. + /// - Throws: `Abort(.notFound)` if the id is missing, not a UUID, or + /// doesn't match any row. private func factionForReq(req: Request) async throws -> BattleTech.Faction { guard let factionIdString = req.parameters.get("faction_id"), let factionUUID = UUID(factionIdString), @@ -80,6 +140,8 @@ extension BattleTech { } } +/// The multipart form body expected by `POST /battletech/factions/import`: a +/// single uploaded file containing the faction XML data. struct FactionMassImport: Content { var file: File } diff --git a/Sources/App/Controllers/BattleTech/MunitionTypeController.swift b/Sources/App/Controllers/BattleTech/MunitionTypeController.swift index c52e267..5ac6d88 100644 --- a/Sources/App/Controllers/BattleTech/MunitionTypeController.swift +++ b/Sources/App/Controllers/BattleTech/MunitionTypeController.swift @@ -1,9 +1,22 @@ +/// Read-only public API for BattleTech munition types (e.g. Standard, Cluster, +/// Inferno ammo variants) and the ammo rows that use each type. Registered under +/// `/battletech/munition-types` by ``BattleTech/RootController``. There is no +/// import endpoint here — munition type rows are populated as a side effect of +/// the ammo CSV import (see ``AmmoImportJob``). import Fluent import Vapor import XMLCoder extension BattleTech { + /// A Vapor `RouteCollection` for `/battletech/munition-types`. struct MunitionTypeController: RouteCollection { + /// Groups routes under `/munition-types`: `GET /` (list), and + /// `GET`/`DELETE /:munition_type_id` plus the nested + /// `GET /:munition_type_id/ammo` listing. + /// + /// - Parameter routes: The `RoutesBuilder` to register routes on. + /// - Throws: Does not currently throw; matches Vapor's `boot(routes:)` + /// signature. func boot(routes: RoutesBuilder) throws { let munitionTypes = routes.grouped("munition-types") munitionTypes.get(use: index).description("All Munition Types") @@ -16,25 +29,61 @@ extension BattleTech { } + /// Serves `GET /battletech/munition-types`. + /// + /// - Parameter req: The incoming `Request`. + /// - Returns: Every ``BattleTech/MunitionType`` row (unpaginated). + /// - Throws: Rethrows errors from the database query. func index(req: Request) async throws -> [BattleTech.MunitionType] { try await BattleTech.MunitionType.query(on: req.db).all() } + /// Serves `GET /battletech/munition-types/:munition_type_id`. + /// + /// - Parameter req: The incoming `Request`; must include a valid + /// `munition_type_id` UUID path parameter (read via `req.parameters`). + /// - Returns: The matching ``BattleTech/MunitionType`` row. + /// - Throws: `Abort(.notFound)` if the id is missing, not a UUID, or + /// doesn't match any row. func show(req: Request) async throws -> BattleTech.MunitionType { return try await getMunitionTypeForRequest(req: req) } + /// Serves `DELETE /battletech/munition-types/:munition_type_id`. + /// + /// - Parameter req: The incoming `Request`; must include a valid + /// `munition_type_id` UUID path parameter. + /// - Returns: `204 No Content` on success. + /// - Throws: `Abort(.notFound)` if the munition type doesn't exist; + /// rethrows database errors otherwise. func delete(req: Request) async throws -> HTTPStatus { let munitionType = try await getMunitionTypeForRequest(req: req) try await munitionType.delete(on: req.db) return .noContent } + /// Serves `GET /battletech/munition-types/:munition_type_id/ammo`. + /// + /// - Parameter req: The incoming `Request`; must include a valid + /// `munition_type_id` UUID path parameter, and honors pagination + /// query params (`page`, `per`). + /// - Returns: A `Page` of ``BattleTech/Ammo`` rows that use this + /// munition type, via the model's `$ammo` relation. + /// - Throws: `Abort(.notFound)` if the munition type doesn't exist; + /// rethrows database errors otherwise. func ammo(req: Request) async throws -> Page { let rule = try await getMunitionTypeForRequest(req: req) return try await rule.$ammo.query(on: req.db).paginate(for: req) } + /// Looks up the ``BattleTech/MunitionType`` row identified by the + /// `munition_type_id` path parameter. + /// + /// - Parameter req: The incoming `Request` supplying the + /// `munition_type_id` path parameter. + /// - Returns: The matching munition type row. + /// - Throws: `Abort(.notFound)` if the id is missing, not a UUID, or + /// doesn't match any row. private func getMunitionTypeForRequest(req: Request) async throws -> BattleTech.MunitionType { guard let idString = req.parameters.get("munition_type_id"), diff --git a/Sources/App/Controllers/BattleTech/RulesController.swift b/Sources/App/Controllers/BattleTech/RulesController.swift index 68b8745..e4e8c48 100644 --- a/Sources/App/Controllers/BattleTech/RulesController.swift +++ b/Sources/App/Controllers/BattleTech/RulesController.swift @@ -1,9 +1,23 @@ +/// Read-only public API for BattleTech construction rules (e.g. Tournament +/// Legal, Standard, Advanced) and the ammo/equipment/weapons legal under each +/// rule. Registered under `/battletech/rules` by ``BattleTech/RootController``. +/// There is no import endpoint here — rule rows are populated as a side effect +/// of the ammo/equipment/weapon CSV imports (each row's `rules()` list is +/// resolved to `Rule` records during that import). import Fluent import Vapor import XMLCoder extension BattleTech { + /// A Vapor `RouteCollection` for `/battletech/rules`. struct RulesController: RouteCollection { + /// Groups routes under `/rules`: `GET /` (list), and + /// `GET`/`DELETE /:rule_id` plus the nested `ammo`/`equipment`/`weapons` + /// listings for a rule. + /// + /// - Parameter routes: The `RoutesBuilder` to register routes on. + /// - Throws: Does not currently throw; matches Vapor's `boot(routes:)` + /// signature. func boot(routes: RoutesBuilder) throws { let rules = routes.grouped("rules") rules.get(use: index).description("All Rules") @@ -16,35 +30,89 @@ extension BattleTech { } } + /// Serves `GET /battletech/rules`. + /// + /// - Parameter req: The incoming `Request`. + /// - Returns: Every ``BattleTech/Rule`` row (unpaginated). + /// - Throws: Rethrows errors from the database query. func index(req: Request) async throws -> [BattleTech.Rule] { try await BattleTech.Rule.query(on: req.db).all() } + /// Serves `GET /battletech/rules/:rule_id`. + /// + /// - Parameter req: The incoming `Request`; must include a valid + /// `rule_id` UUID path parameter (read via `req.parameters`). + /// - Returns: The matching ``BattleTech/Rule`` row. + /// - Throws: `Abort(.notFound)` if the id is missing, not a UUID, or + /// doesn't match any row. func show(req: Request) async throws -> BattleTech.Rule { try await getRuleForRequest(req: req) } + /// Serves `DELETE /battletech/rules/:rule_id`. + /// + /// - Parameter req: The incoming `Request`; must include a valid + /// `rule_id` UUID path parameter. + /// - Returns: `204 No Content` on success. + /// - Throws: `Abort(.notFound)` if the rule doesn't exist; rethrows + /// database errors otherwise. func delete(req: Request) async throws -> HTTPStatus { let rule = try await getRuleForRequest(req: req) try await rule.delete(on: req.db) return .noContent } + /// Serves `GET /battletech/rules/:rule_id/ammo`. + /// + /// - Parameter req: The incoming `Request`; must include a valid + /// `rule_id` UUID path parameter, and honors pagination query params + /// (`page`, `per`). + /// - Returns: A `Page` of ``BattleTech/Ammo`` rows legal under this + /// rule, via the model's `$ammo` relation. + /// - Throws: `Abort(.notFound)` if the rule doesn't exist; rethrows + /// database errors otherwise. func ammo(req: Request) async throws -> Page { let rule = try await getRuleForRequest(req: req) return try await rule.$ammo.query(on: req.db).paginate(for: req) } + /// Serves `GET /battletech/rules/:rule_id/equipment`. + /// + /// - Parameter req: The incoming `Request`; must include a valid + /// `rule_id` UUID path parameter, and honors pagination query params + /// (`page`, `per`). + /// - Returns: A `Page` of ``BattleTech/Equipment`` rows legal under this + /// rule, via the model's `$equipment` relation. + /// - Throws: `Abort(.notFound)` if the rule doesn't exist; rethrows + /// database errors otherwise. func equipment(req: Request) async throws -> Page { let rule = try await getRuleForRequest(req: req) return try await rule.$equipment.query(on: req.db).paginate(for: req) } + /// Serves `GET /battletech/rules/:rule_id/weapons`. + /// + /// - Parameter req: The incoming `Request`; must include a valid + /// `rule_id` UUID path parameter, and honors pagination query params + /// (`page`, `per`). + /// - Returns: A `Page` of ``BattleTech/Weapon`` rows legal under this + /// rule, via the model's `$weapons` relation. + /// - Throws: `Abort(.notFound)` if the rule doesn't exist; rethrows + /// database errors otherwise. func weapons(req: Request) async throws -> Page { let rule = try await getRuleForRequest(req: req) return try await rule.$weapons.query(on: req.db).paginate(for: req) } + /// Looks up the ``BattleTech/Rule`` row identified by the `rule_id` path + /// parameter. + /// + /// - Parameter req: The incoming `Request` supplying the `rule_id` path + /// parameter. + /// - Returns: The matching rule row. + /// - Throws: `Abort(.notFound)` if the id is missing, not a UUID, or + /// doesn't match any row. private func getRuleForRequest(req: Request) async throws -> BattleTech.Rule { guard let idString = req.parameters.get("rule_id"), let ruleUUID = UUID(idString), diff --git a/Sources/App/Controllers/BattleTech/TechBaseController.swift b/Sources/App/Controllers/BattleTech/TechBaseController.swift index d1a16e3..dc4fc9f 100644 --- a/Sources/App/Controllers/BattleTech/TechBaseController.swift +++ b/Sources/App/Controllers/BattleTech/TechBaseController.swift @@ -1,9 +1,22 @@ +/// Read-only public API for BattleTech tech bases (Inner Sphere, Clan, Mixed, +/// etc.) and the ammo/equipment/weapons that use each one. Registered under +/// `/battletech/tech-bases` by ``BattleTech/RootController``. There is no import +/// endpoint here — tech base rows are populated as a side effect of the +/// ammo/equipment/weapon CSV imports. import Fluent import Vapor import XMLCoder extension BattleTech { + /// A Vapor `RouteCollection` for `/battletech/tech-bases`. struct TechBaseController: RouteCollection { + /// Groups routes under `/tech-bases`: `GET /` (list), and + /// `GET`/`DELETE /:tech_base_id` plus the nested + /// `ammo`/`equipment`/`weapons` listings for a tech base. + /// + /// - Parameter routes: The `RoutesBuilder` to register routes on. + /// - Throws: Does not currently throw; matches Vapor's `boot(routes:)` + /// signature. func boot(routes: RoutesBuilder) throws { let techBase = routes.grouped("tech-bases") techBase.get(use: index).description("All Tech Bases") @@ -16,35 +29,89 @@ extension BattleTech { } } + /// Serves `GET /battletech/tech-bases`. + /// + /// - Parameter req: The incoming `Request`. + /// - Returns: Every ``BattleTech/TechBase`` row (unpaginated). + /// - Throws: Rethrows errors from the database query. func index(req: Request) async throws -> [BattleTech.TechBase] { try await BattleTech.TechBase.query(on: req.db).all() } + /// Serves `GET /battletech/tech-bases/:tech_base_id`. + /// + /// - Parameter req: The incoming `Request`; must include a valid + /// `tech_base_id` UUID path parameter (read via `req.parameters`). + /// - Returns: The matching ``BattleTech/TechBase`` row. + /// - Throws: `Abort(.notFound)` if the id is missing, not a UUID, or + /// doesn't match any row. func show(req: Request) async throws -> BattleTech.TechBase { try await getTechBaseForRequest(req: req) } + /// Serves `DELETE /battletech/tech-bases/:tech_base_id`. + /// + /// - Parameter req: The incoming `Request`; must include a valid + /// `tech_base_id` UUID path parameter. + /// - Returns: `204 No Content` on success. + /// - Throws: `Abort(.notFound)` if the tech base doesn't exist; rethrows + /// database errors otherwise. func delete(req: Request) async throws -> HTTPStatus { let techBase = try await getTechBaseForRequest(req: req) try await techBase.delete(on: req.db) return .noContent } + /// Serves `GET /battletech/tech-bases/:tech_base_id/ammo`. + /// + /// - Parameter req: The incoming `Request`; must include a valid + /// `tech_base_id` UUID path parameter, and honors pagination query + /// params (`page`, `per`). + /// - Returns: A `Page` of ``BattleTech/Ammo`` rows using this tech base, + /// via the model's `$ammo` relation. + /// - Throws: `Abort(.notFound)` if the tech base doesn't exist; rethrows + /// database errors otherwise. func ammo(req: Request) async throws -> Page { let techBase = try await getTechBaseForRequest(req: req) return try await techBase.$ammo.query(on: req.db).paginate(for: req) } + /// Serves `GET /battletech/tech-bases/:tech_base_id/equipment`. + /// + /// - Parameter req: The incoming `Request`; must include a valid + /// `tech_base_id` UUID path parameter, and honors pagination query + /// params (`page`, `per`). + /// - Returns: A `Page` of ``BattleTech/Equipment`` rows using this tech + /// base, via the model's `$equipment` relation. + /// - Throws: `Abort(.notFound)` if the tech base doesn't exist; rethrows + /// database errors otherwise. func equipment(req: Request) async throws -> Page { let techBase = try await getTechBaseForRequest(req: req) return try await techBase.$equipment.query(on: req.db).paginate(for: req) } + /// Serves `GET /battletech/tech-bases/:tech_base_id/weapons`. + /// + /// - Parameter req: The incoming `Request`; must include a valid + /// `tech_base_id` UUID path parameter, and honors pagination query + /// params (`page`, `per`). + /// - Returns: A `Page` of ``BattleTech/Weapon`` rows using this tech + /// base, via the model's `$weapons` relation. + /// - Throws: `Abort(.notFound)` if the tech base doesn't exist; rethrows + /// database errors otherwise. func weapons(req: Request) async throws -> Page { let techBase = try await getTechBaseForRequest(req: req) return try await techBase.$weapons.query(on: req.db).paginate(for: req) } + /// Looks up the ``BattleTech/TechBase`` row identified by the + /// `tech_base_id` path parameter. + /// + /// - Parameter req: The incoming `Request` supplying the `tech_base_id` + /// path parameter. + /// - Returns: The matching tech base row. + /// - Throws: `Abort(.notFound)` if the id is missing, not a UUID, or + /// doesn't match any row. private func getTechBaseForRequest(req: Request) async throws -> BattleTech.TechBase { guard let idString = req.parameters.get("tech_base_id"), let techBaseUUID = UUID(idString), diff --git a/Sources/App/Controllers/BattleTech/TechLevelController.swift b/Sources/App/Controllers/BattleTech/TechLevelController.swift index c1023eb..2b2a97d 100644 --- a/Sources/App/Controllers/BattleTech/TechLevelController.swift +++ b/Sources/App/Controllers/BattleTech/TechLevelController.swift @@ -1,9 +1,23 @@ +/// Read-only public API for BattleTech static tech levels (e.g. Standard, +/// Advanced, Experimental, Unofficial) and the ammo/equipment/weapons at each +/// level. Registered under `/battletech/tech-levels` by +/// ``BattleTech/RootController``. There is no import endpoint here — tech level +/// rows are populated as a side effect of the ammo/equipment/weapon CSV imports +/// (via each row's `staticTechLevel()`). import Fluent import Vapor import XMLCoder extension BattleTech { + /// A Vapor `RouteCollection` for `/battletech/tech-levels`. struct TechLevelController: RouteCollection { + /// Groups routes under `/tech-levels`: `GET /` (list), and + /// `GET`/`DELETE /:tech_level_id` plus the nested + /// `ammo`/`equipment`/`weapons` listings for a tech level. + /// + /// - Parameter routes: The `RoutesBuilder` to register routes on. + /// - Throws: Does not currently throw; matches Vapor's `boot(routes:)` + /// signature. func boot(routes: RoutesBuilder) throws { let techLevels = routes.grouped("tech-levels") techLevels.get(use: index).description("All Tech Levels") @@ -17,35 +31,89 @@ extension BattleTech { } + /// Serves `GET /battletech/tech-levels`. + /// + /// - Parameter req: The incoming `Request`. + /// - Returns: Every ``BattleTech/TechLevel`` row (unpaginated). + /// - Throws: Rethrows errors from the database query. func index(req: Request) async throws -> [BattleTech.TechLevel] { try await BattleTech.TechLevel.query(on: req.db).all() } + /// Serves `GET /battletech/tech-levels/:tech_level_id`. + /// + /// - Parameter req: The incoming `Request`; must include a valid + /// `tech_level_id` UUID path parameter (read via `req.parameters`). + /// - Returns: The matching ``BattleTech/TechLevel`` row. + /// - Throws: `Abort(.notFound)` if the id is missing, not a UUID, or + /// doesn't match any row. func show(req: Request) async throws -> BattleTech.TechLevel { try await getTechLevelForRequest(req: req) } + /// Serves `DELETE /battletech/tech-levels/:tech_level_id`. + /// + /// - Parameter req: The incoming `Request`; must include a valid + /// `tech_level_id` UUID path parameter. + /// - Returns: `204 No Content` on success. + /// - Throws: `Abort(.notFound)` if the tech level doesn't exist; + /// rethrows database errors otherwise. func delete(req: Request) async throws -> HTTPStatus { let techLevel = try await getTechLevelForRequest(req: req) try await techLevel.delete(on: req.db) return .noContent } + /// Serves `GET /battletech/tech-levels/:tech_level_id/ammo`. + /// + /// - Parameter req: The incoming `Request`; must include a valid + /// `tech_level_id` UUID path parameter, and honors pagination query + /// params (`page`, `per`). + /// - Returns: A `Page` of ``BattleTech/Ammo`` rows at this tech level, + /// via the model's `$ammo` relation. + /// - Throws: `Abort(.notFound)` if the tech level doesn't exist; + /// rethrows database errors otherwise. func ammo(req: Request) async throws -> Page { let techLevel = try await getTechLevelForRequest(req: req) return try await techLevel.$ammo.query(on: req.db).paginate(for: req) } + /// Serves `GET /battletech/tech-levels/:tech_level_id/equipment`. + /// + /// - Parameter req: The incoming `Request`; must include a valid + /// `tech_level_id` UUID path parameter, and honors pagination query + /// params (`page`, `per`). + /// - Returns: A `Page` of ``BattleTech/Equipment`` rows at this tech + /// level, via the model's `$equipment` relation. + /// - Throws: `Abort(.notFound)` if the tech level doesn't exist; + /// rethrows database errors otherwise. func equipment(req: Request) async throws -> Page { let techLevel = try await getTechLevelForRequest(req: req) return try await techLevel.$equipment.query(on: req.db).paginate(for: req) } + /// Serves `GET /battletech/tech-levels/:tech_level_id/weapons`. + /// + /// - Parameter req: The incoming `Request`; must include a valid + /// `tech_level_id` UUID path parameter, and honors pagination query + /// params (`page`, `per`). + /// - Returns: A `Page` of ``BattleTech/Weapon`` rows at this tech level, + /// via the model's `$weapons` relation. + /// - Throws: `Abort(.notFound)` if the tech level doesn't exist; + /// rethrows database errors otherwise. func weapons(req: Request) async throws -> Page { let techLevel = try await getTechLevelForRequest(req: req) return try await techLevel.$weapons.query(on: req.db).paginate(for: req) } + /// Looks up the ``BattleTech/TechLevel`` row identified by the + /// `tech_level_id` path parameter. + /// + /// - Parameter req: The incoming `Request` supplying the + /// `tech_level_id` path parameter. + /// - Returns: The matching tech level row. + /// - Throws: `Abort(.notFound)` if the id is missing, not a UUID, or + /// doesn't match any row. private func getTechLevelForRequest(req: Request) async throws -> BattleTech.TechLevel { guard let idString = req.parameters.get("tech_level_id"), let techLevelUUID = UUID(idString), diff --git a/Sources/App/Controllers/BattleTech/WeaponController.swift b/Sources/App/Controllers/BattleTech/WeaponController.swift index f13b7a0..3d76d65 100644 --- a/Sources/App/Controllers/BattleTech/WeaponController.swift +++ b/Sources/App/Controllers/BattleTech/WeaponController.swift @@ -1,9 +1,20 @@ +/// Read-only public API for BattleTech weapon data, plus an admin CSV-upload +/// endpoint for (re)populating it. Registered under `/battletech/weapons` by +/// ``BattleTech/RootController``. import Fluent import SwiftCSV import Vapor extension BattleTech { + /// A Vapor `RouteCollection` for `/battletech/weapons`. struct WeaponController: RouteCollection { + /// Groups routes under `/weapons`: `GET /` (paginated list), + /// `POST /import` (mass CSV import), and `GET`/`DELETE /:weapon_id` + /// (single-item show/delete). + /// + /// - Parameter routes: The `RoutesBuilder` to register routes on. + /// - Throws: Does not currently throw; matches Vapor's `boot(routes:)` + /// signature. func boot(routes: RoutesBuilder) throws { let weapons = routes.grouped("weapons") weapons.get(use: index).description("All Weapons") @@ -14,6 +25,14 @@ extension BattleTech { } } + /// Serves `GET /battletech/weapons`. + /// + /// - Parameter req: The incoming `Request`; standard Fluent pagination + /// query params (`page`, `per`) are honored via `paginate(for:)`. + /// - Returns: A `Page` of ``BattleTech/Weapon`` rows, with their tech + /// base, applicable rules, static tech level, and aliases eagerly + /// loaded (via Fluent's `Model.query(on:)` + `.with(_:)`). + /// - Throws: Rethrows errors from the database query. func index(req: Request) async throws -> Page { try await BattleTech.Weapon.query(on: req.db) .with(\.$techBase) @@ -23,10 +42,26 @@ extension BattleTech { .paginate(for: req) } + /// Serves `GET /battletech/weapons/:weapon_id`. + /// + /// - Parameter req: The incoming `Request`; must include a valid + /// `weapon_id` UUID path parameter (read via `req.parameters`). + /// - Returns: The matching ``BattleTech/Weapon`` row with its relations + /// eagerly loaded. + /// - Throws: `Abort(.notFound)` if the id is missing, not a UUID, or + /// doesn't match any row. func show(req: Request) async throws -> BattleTech.Weapon { return try await weaponForRequest(req: req) } + /// Serves `DELETE /battletech/weapons/:weapon_id`. + /// + /// - Parameter req: The incoming `Request`; must include a valid + /// `weapon_id` UUID path parameter. + /// - Returns: `204 No Content` on success. + /// - Throws: `Abort(.notFound)` if the weapon doesn't exist; rethrows + /// database errors otherwise. Detaches the weapon's rules pivot rows + /// before deleting so the many-to-many join table doesn't dangle. func delete(req: Request) async throws -> HTTPStatus { let weapon = try await weaponForRequest(req: req) try await weapon.$rules.detachAll(on: req.db) @@ -34,6 +69,20 @@ extension BattleTech { return .noContent } + /// Serves `POST /battletech/weapons/import`. An admin-facing bulk-load + /// endpoint: decodes an uploaded weapon CSV file (`req.content` reads the + /// multipart body into a ``WeaponMassImport``), parses it row by row with + /// ``Importers/WeaponCSVRow``, skips rows marked "Unofficial" in their + /// rules/tech-level columns, and dispatches the rest as + /// ``WeaponImportJob`` background jobs (each retried up to 5 times) + /// rather than writing them to the database inline. + /// + /// - Parameter req: The incoming `Request`; the body must be + /// multipart/form-data containing a `file` field with CSV content. + /// - Returns: `201 Created` once every row has been queued (not once + /// every row has actually been imported — importing happens + /// asynchronously). + /// - Throws: Rethrows errors from decoding the upload or dispatching jobs. func massCreate(req: Request) async throws -> HTTPStatus { let input = try req.content.decode(WeaponMassImport.self) let csvString = String(decoding: Data(buffer: input.file.data), as: UTF8.self) @@ -54,6 +103,14 @@ extension BattleTech { return .created } + /// Looks up the ``BattleTech/Weapon`` row identified by the `weapon_id` + /// path parameter, with its relations eagerly loaded. + /// + /// - Parameter req: The incoming `Request` supplying the `weapon_id` + /// path parameter. + /// - Returns: The matching weapon row. + /// - Throws: `Abort(.notFound)` if the id is missing, not a UUID, or + /// doesn't match any row. private func weaponForRequest(req: Request) async throws -> BattleTech.Weapon { guard let weaponIdString = req.parameters.get("weapon_id"), let weaponUUID = UUID(weaponIdString), @@ -73,6 +130,8 @@ extension BattleTech { } } +/// The multipart form body expected by `POST /battletech/weapons/import`: a single +/// uploaded file containing the weapon CSV data. struct WeaponMassImport: Content { var file: File } diff --git a/Sources/App/Controllers/BattleTechController.swift b/Sources/App/Controllers/BattleTechController.swift index 3066018..b2b0cdf 100644 --- a/Sources/App/Controllers/BattleTechController.swift +++ b/Sources/App/Controllers/BattleTechController.swift @@ -5,11 +5,23 @@ // Author: Richard J Hancock // Date: 2024/02/12 // +/// Root of the public `/battletech` route namespace, registered from +/// `routes.swift`. This is the read-only BattleTech game-data API (weapons, +/// equipment, ammo, factions, eras, rules, tech levels/bases) sourced from the +/// MegaMek project. import Vapor extension BattleTech { + /// A Vapor `RouteCollection` that mounts every BattleTech data sub-controller + /// under `/battletech`. struct RootController: RouteCollection { + /// Groups all routes under the `/battletech` path prefix and registers + /// each domain sub-controller (ammo, equipment, eras, factions, munition + /// types, rules, tech bases, tech levels, weapons) beneath it. + /// + /// - Parameter routes: The `RoutesBuilder` to register routes on. + /// - Throws: Rethrows any error from registering a sub-controller. func boot(routes: any RoutesBuilder) throws { let battletech = routes.grouped("battletech") try battletech.register(collection: BattleTech.AmmoController()) diff --git a/Sources/App/Controllers/ServersController.swift b/Sources/App/Controllers/ServersController.swift new file mode 100644 index 0000000..67bf0ba --- /dev/null +++ b/Sources/App/Controllers/ServersController.swift @@ -0,0 +1,103 @@ +// +// ServersController.swift +// mul-api +// +// Created by Richard Hancock on 12/29/25. +// +// Only here to support transition to new API system for Announcement system +/// Legacy top-level `/servers` route collection used by MegaMek game clients to +/// "announce" themselves so they show up in a public server list — e.g. a running +/// MegaMek dedicated server periodically POSTs its address/port/player count here. +/// Registered directly from `routes.swift` (not nested under `/api` or +/// `/battletech`); kept during the transition to the versioned +/// ``Api/V1/ServersController``. + +import Fluent +import Vapor + +/// A Vapor `RouteCollection` for the legacy server-announce endpoints. +struct ServersController: RouteCollection { + /// Groups routes under `/servers`: `GET /` (list) and `POST /announce` + /// (create-or-update). + /// + /// - Parameter routes: The `RoutesBuilder` to register routes on. + /// - Throws: Does not currently throw; matches Vapor's `boot(routes:)` + /// signature. + func boot(routes: any RoutesBuilder) throws { + let servers = routes.grouped("servers") + servers.get(use: index).description("Get A List Of Servers") + servers.post("announce", use: create).description("Add/Update A Server To The List") + } + + /// Serves `GET /servers`. + /// + /// - Parameter req: The incoming `Request`. + /// - Returns: Every ``MegaMek/Server`` row currently in the database, as JSON + /// (unpaginated) — the public server list MegaMek clients display. + /// - Throws: Rethrows errors from the database query. + func index(req: Request) async throws -> [MegaMek.Server] { + return try await MegaMek.Server.query(on: req.db).all() + } + + /// Serves `POST /servers/announce`. A MegaMek server calls this (typically on + /// a timer) to register itself or refresh its listing. + /// + /// Decodes a ``MegaMek/ServerDTO`` from the request body and determines the + /// caller's IP from the TCP connection (`req.peerAddress`) rather than trusting + /// a client-supplied address. If the DTO includes a `key` matching an existing + /// server, that server's row is updated in place; otherwise a new server row is + /// created (its key is generated by the model). + /// + /// - Parameter req: The incoming `Request`; its body must decode as + /// ``MegaMek/ServerDTO`` (port, optional password flag, connected users, + /// version, optional phase/MOTD, and an optional existing `key`). + /// - Returns: The server's `serverKey`, so the caller can include it on + /// subsequent announce calls to update the same listing instead of creating + /// duplicates. + /// - Throws: `Abort(.badRequest)` if the client's IP address cannot be + /// determined; rethrows errors from decoding the body or from the database + /// query/save. + func create(req: Request) async throws -> String { + let dto = try req.content.decode(MegaMek.ServerDTO.self) + + guard let ipAddress = req.peerAddress?.ipAddress else { + throw Abort(.badRequest, reason: "Unable to determine client IP address") + } + + let passworded = dto.passworded ?? false + let users = dto.users.joined(separator: ", ") + + var existing: MegaMek.Server? + if let key = dto.key { + existing = try await MegaMek.Server.query(on: req.db) + .filter(\.$serverKey == key) + .first() + } + + let server: MegaMek.Server + if let existing { + existing.ipAddress = ipAddress + existing.port = dto.port + existing.passworded = passworded + existing.users = users + existing.version = dto.version + existing.phase = dto.phase ?? existing.phase + existing.motd = dto.motd + server = existing + } else { + server = MegaMek.Server( + port: dto.port, + ipAddress: ipAddress, + passworded: passworded, + users: users, + version: dto.version, + phase: dto.phase ?? "", + motd: dto.motd + ) + } + + try await server.save(on: req.db) + + return server.serverKey + } +} diff --git a/Sources/App/DTOs/MegaMek/ServerDTO.swift b/Sources/App/DTOs/MegaMek/ServerDTO.swift new file mode 100644 index 0000000..dbebb67 --- /dev/null +++ b/Sources/App/DTOs/MegaMek/ServerDTO.swift @@ -0,0 +1,29 @@ +// +// ServerDTO.swift +// mul-api +// +// Created by Richard Hancock on 12/29/25. +// + +/// DTO (Data Transfer Object) for a MegaMek client's server-announcement request — the JSON +/// body a running MegaMek game server POSTs to this API to advertise itself, decoded by +/// `ServersController.create(_:)` into a ``MegaMek/Server`` database record. + +extension MegaMek { + /// The wire shape of an incoming server announcement. Deliberately shaped differently + /// from ``MegaMek/Server``: `users` arrives as an array here (joined into a single + /// comma-separated string on the model), several fields are optional here but required + /// or defaulted on the model, and `key` — when present — is the `serverKey` from a + /// previous announcement, letting the controller update that existing record instead of + /// creating a duplicate one; `ipAddress` isn't part of this DTO at all since it's taken + /// from the request's actual peer address rather than trusted from client input. + struct ServerDTO : Codable { + var port: Int + var version: String + var phase: String? + var passworded: Bool? + var users: [String] + var motd: String? + var key: String? + } +} diff --git a/Sources/App/Extensions/String+Base64URLSafe.swift b/Sources/App/Extensions/String+Base64URLSafe.swift index 4fb3b85..f58a43b 100644 --- a/Sources/App/Extensions/String+Base64URLSafe.swift +++ b/Sources/App/Extensions/String+Base64URLSafe.swift @@ -8,8 +8,15 @@ import Foundation +/// Adds a helper for producing a URL-safe variant of a base64 string, so it can be +/// safely embedded in a query parameter (e.g. confirmation tokens) without needing +/// percent-encoding. extension String { - /// Encodes to a URL Safe base64 String + /// Converts a standard base64 string into its URL-safe form by swapping the + /// characters that are unsafe in URLs and dropping padding. + /// + /// - Returns: The string with `/` replaced with `_`, `+` replaced with `-`, and + /// `=` padding removed. func base64URLSafe() -> String { self.replacingOccurrences(of: "/", with: "_") .replacingOccurrences(of: "+", with: "-") diff --git a/Sources/App/Importers/Ammo+Import.swift b/Sources/App/Importers/Ammo+Import.swift index e8d375c..b0d21f2 100644 --- a/Sources/App/Importers/Ammo+Import.swift +++ b/Sources/App/Importers/Ammo+Import.swift @@ -1,3 +1,10 @@ +/// Parses the ammo CSV file uploaded to `POST /battletech/ammo/import` (handled by +/// ``BattleTech/AmmoController/massCreate(req:)``) into typed rows. Each row is +/// dispatched as an ``AmmoImportJob`` payload, whose `dequeue` writes it into the +/// ``BattleTech/Ammo`` table. + +/// The column order of the ammo CSV file, used as raw-value indexes into each row's +/// array of string cells. extension Importers { enum AmmoHeaders: Int { case name @@ -29,18 +36,28 @@ extension Importers { } } +/// A single row of the ammo import CSV, exposed as typed accessors over the raw +/// array of string cells (indexed via ``Importers/AmmoHeaders``). Built by +/// ``BattleTech/AmmoController/massCreate(req:)`` for each CSV row and queued as +/// the payload for ``AmmoImportJob``. extension Importers { struct AmmoCSVRow: Codable { + /// Wraps one already-split CSV row (one string per column). init(row: [String]) { self.row = row } private let row: [String] + /// The ammo's display name. func name() -> String { row[Importers.AmmoHeaders.name.rawValue] } + /// The tech base (e.g. Inner Sphere, Clan, Mixed) as raw CSV text. func techBase() -> String { row[Importers.AmmoHeaders.techBase.rawValue] } + /// The raw, unsplit rules-reference cell (e.g. `"Rules Level/Unofficial"`). func rulesRaw() -> String { row[Importers.AmmoHeaders.rules.rawValue] } + /// The construction rules this ammo is legal under, split on `/` into + /// individual rule names (e.g. Tournament Legal, Standard, Advanced). func rules() -> [String] { row[Importers.AmmoHeaders.rules.rawValue].split( separator: "/", @@ -48,93 +65,129 @@ extension Importers { ).map(String.init) } + /// The tech rating letter code (e.g. `"D"`, `"F"`). func techRating() -> String { row[Importers.AmmoHeaders.techRating.rawValue] } + /// The static tech level string (e.g. `"Standard"`, `"Unofficial"`); used by + /// callers to skip unofficial rows before importing. func staticTechLevel() -> String { row[Importers.AmmoHeaders.staticTechLevel.rawValue] } + /// The in-universe year this ammo was introduced, or `nil` if the cell is + /// blank/missing. func introductionDate() -> String? { let value = row[Importers.AmmoHeaders.introductionDate.rawValue] return value.count > 1 ? value : nil } + /// The in-universe year this ammo reached prototype status, or `nil` if the + /// cell is blank/missing. func prototypeDate() -> String? { let value = row[Importers.AmmoHeaders.prototypeDate.rawValue] return value.count > 1 ? value : nil } + /// The in-universe year this ammo reached full production, or `nil` if the + /// cell is blank/missing. func productionDate() -> String? { let value = row[Importers.AmmoHeaders.productionDate.rawValue] return value.count > 1 ? value : nil } + /// The in-universe year this ammo became common, or `nil` if the cell is + /// blank/missing. func commonDate() -> String? { let value = row[Importers.AmmoHeaders.commonDate.rawValue] return value.count > 1 ? value : nil } + /// The in-universe year this ammo went extinct, or `nil` if the cell is + /// blank/missing. func extinctionDate() -> String? { let value = row[Importers.AmmoHeaders.extinctionDate.rawValue] return value.count > 1 ? value : nil } + /// The in-universe year this ammo was reintroduced after extinction, or + /// `nil` if the cell is blank/missing. func reIntroductionDate() -> String? { let value = row[Importers.AmmoHeaders.reIntroductionDate.rawValue] return value.count > 1 ? value : nil } + /// The ammo's weight in tons, parsed from the CSV cell (`0.0` if unparsable). func tonnage() -> Double { Double(row[Importers.AmmoHeaders.tonnage.rawValue]) ?? 0.0 } + /// The number of critical slots this ammo occupies (`0` if unparsable). func criticalSlots() -> Int { Int(row[Importers.AmmoHeaders.criticalSlots.rawValue]) ?? 0 } + /// The in-universe C-bill cost (`0.0` if unparsable). func cost() -> Double { Double(row[Importers.AmmoHeaders.cost.rawValue]) ?? 0.0 } + /// The Battle Value contribution of this ammo (`0.0` if unparsable). func battleValue() -> Double { Double(row[Importers.AmmoHeaders.battleValue.rawValue]) ?? 0.0 } + /// The specific rulebook/page reference for this ammo. func rulesReference() -> String { row[Importers.AmmoHeaders.rulesReference.rawValue] } + /// Whether this ammo counts as flak for anti-aircraft purposes. func countAsFlak() -> Bool { let value = row[Importers.AmmoHeaders.countAsFlak.rawValue] return value == "TRUE" } + /// The munition type name (e.g. `"Standard"`, `"Cluster"`), with the CSV's + /// surrounding square brackets stripped. func munitionType() -> String { row[Importers.AmmoHeaders.munitionType.rawValue] .replacingOccurrences(of: "[", with: "") .replacingOccurrences(of: "]", with: "") } + /// Damage dealt per shot fired (`0` if unparsable). func damagePerShot() -> Int { Int(row[Importers.AmmoHeaders.damagePerShot.rawValue]) ?? 0 } + /// The weapon rack size this ammo is sized for (`0` if unparsable). func rackSize() -> Int { Int(row[Importers.AmmoHeaders.rackSize.rawValue]) ?? 0 } + /// The number of shots per ton of ammo (`0` if unparsable). func shots() -> Int { Int(row[Importers.AmmoHeaders.shots.rawValue]) ?? 0 } + /// The ammo-to-weapon ratio used in some construction rules (`0.0` if + /// unparsable). func ammoRatio() -> Double { Double(row[Importers.AmmoHeaders.ammoRatio.rawValue]) ?? 0.0 } + /// Whether this is capital-scale ammo. func isCapital() -> Bool { - let value = row[Importers.AmmoHeaders.countAsFlak.rawValue] + let value = row[Importers.AmmoHeaders.isCapital.rawValue] return value == "TRUE" } + /// Mass per shot in kilograms, for ammo types measured that way (`0.0` if + /// unparsable). func kilogramPerShot() -> Double { Double(row[Importers.AmmoHeaders.kilogramPerShot.rawValue]) ?? 0.0 } + /// Whether this ammo is usable by aerospace units. func aeroUse() -> Bool { - let value = row[Importers.AmmoHeaders.countAsFlak.rawValue] + let value = row[Importers.AmmoHeaders.aeroUse.rawValue] return value == "TRUE" } + /// The alias cell with this ammo's own name stripped out, leaving only the + /// comma-separated alternate names. func rawAlias() -> String { row[Importers.EquipmentHeaders.alias.rawValue].replacingOccurrences(of: self.name(), with: "") } + /// The alternate/alias names for this ammo, split, deduplicated, and + /// trimmed of the primary name. func alias() -> [String] { self.rawAlias().split( separator: ",", diff --git a/Sources/App/Importers/Equipment+Import.swift b/Sources/App/Importers/Equipment+Import.swift index 80b15c8..d8e82b3 100644 --- a/Sources/App/Importers/Equipment+Import.swift +++ b/Sources/App/Importers/Equipment+Import.swift @@ -1,3 +1,10 @@ +/// Parses the equipment CSV file uploaded to `POST /battletech/equipment/import` +/// (handled by ``BattleTech/EquipmentController/massCreate(req:)``) into typed rows. +/// Each row is dispatched as an ``EquipmentImportJob`` payload, whose `dequeue` +/// writes it into the ``BattleTech/Equipment`` table. + +/// The column order of the equipment CSV file, used as raw-value indexes into each +/// row's array of string cells. extension Importers { enum EquipmentHeaders: Int { case name @@ -20,18 +27,28 @@ extension Importers { } } +/// A single row of the equipment import CSV, exposed as typed accessors over the +/// raw array of string cells (indexed via ``Importers/EquipmentHeaders``). Built by +/// ``BattleTech/EquipmentController/massCreate(req:)`` for each CSV row and queued +/// as the payload for ``EquipmentImportJob``. extension Importers { struct EquipmentCSVRow: Codable { + /// Wraps one already-split CSV row (one string per column). init(row: [String]) { self.row = row } private let row: [String] + /// The equipment's display name. func name() -> String { row[Importers.EquipmentHeaders.name.rawValue] } + /// The tech base (e.g. Inner Sphere, Clan, Mixed) as raw CSV text. func techBase() -> String { row[Importers.EquipmentHeaders.techBase.rawValue] } + /// The raw, unsplit rules-reference cell (e.g. `"Rules Level/Unofficial"`). func rulesRaw() -> String { row[Importers.EquipmentHeaders.rules.rawValue] } + /// The construction rules this equipment is legal under, split on `/` into + /// individual rule names (e.g. Tournament Legal, Standard, Advanced). func rules() -> [String] { row[Importers.EquipmentHeaders.rules.rawValue].split( separator: "/", @@ -39,64 +56,90 @@ extension Importers { ).map(String.init) } + /// The tech rating letter code (e.g. `"D"`, `"F"`). func techRating() -> String { row[Importers.EquipmentHeaders.techRating.rawValue] } + /// The static tech level string (e.g. `"Standard"`, `"Unofficial"`); used by + /// callers to skip unofficial rows before importing. func staticTechLevel() -> String { row[Importers.EquipmentHeaders.staticTechLevel.rawValue] } + /// The in-universe year this equipment was introduced, or `nil` if the cell + /// is blank/missing. func introductionDate() -> String? { let value = row[Importers.EquipmentHeaders.introductionDate.rawValue] return value.count > 1 ? value : nil } + /// The in-universe year this equipment reached prototype status, or `nil` + /// if the cell is blank/missing. func prototypeDate() -> String? { let value = row[Importers.EquipmentHeaders.prototypeDate.rawValue] return value.count > 1 ? value : nil } + /// The in-universe year this equipment reached full production, or `nil` if + /// the cell is blank/missing. func productionDate() -> String? { let value = row[Importers.EquipmentHeaders.productionDate.rawValue] return value.count > 1 ? value : nil } + /// The in-universe year this equipment became common, or `nil` if the cell + /// is blank/missing. func commonDate() -> String? { let value = row[Importers.EquipmentHeaders.commonDate.rawValue] return value.count > 1 ? value : nil } + /// The in-universe year this equipment went extinct, or `nil` if the cell is + /// blank/missing. func extinctionDate() -> String? { let value = row[Importers.EquipmentHeaders.extinctionDate.rawValue] return value.count > 1 ? value : nil } + /// The in-universe year this equipment was reintroduced after extinction, + /// or `nil` if the cell is blank/missing. func reIntroductionDate() -> String? { let value = row[Importers.EquipmentHeaders.reIntroductionDate.rawValue] return value.count > 1 ? value : nil } + /// The equipment's weight in tons, parsed from the CSV cell (`-1.0` if + /// unparsable, distinguishing "unknown" from a real zero weight). func tonnage() -> Double { Double(row[Importers.EquipmentHeaders.tonnage.rawValue]) ?? -1.0 } + /// The number of critical slots this equipment occupies (`-1` if + /// unparsable). func criticalSlots() -> Int { Int(row[Importers.EquipmentHeaders.criticalSlots.rawValue]) ?? -1 } + /// The in-universe C-bill cost (`-1.0` if unparsable). func cost() -> Double { Double(row[Importers.EquipmentHeaders.cost.rawValue]) ?? -1.0 } + /// The Battle Value contribution of this equipment (`-1.0` if unparsable). func battleValue() -> Double { Double(row[Importers.EquipmentHeaders.battleValue.rawValue]) ?? -1.0 } + /// The specific rulebook/page reference for this equipment. func rulesReference() -> String { row[Importers.EquipmentHeaders.rulesReference.rawValue] } + /// The alias cell with this equipment's own name stripped out, leaving only + /// the comma-separated alternate names. func rawAlias() -> String { row[Importers.EquipmentHeaders.alias.rawValue].replacingOccurrences(of: self.name(), with: "") } + /// The alternate/alias names for this equipment, split, deduplicated, and + /// trimmed of the primary name. func alias() -> [String] { self.rawAlias().split( separator: ",", diff --git a/Sources/App/Importers/Era+Import.swift b/Sources/App/Importers/Era+Import.swift index 2ada0be..343c86f 100644 --- a/Sources/App/Importers/Era+Import.swift +++ b/Sources/App/Importers/Era+Import.swift @@ -1,3 +1,9 @@ +/// Defines the shape of the era XML file uploaded to `POST /battletech/eras/import` +/// (handled by ``BattleTech/EraController/massCreate(req:)``) and the logic for +/// turning each parsed `` element into a ``BattleTech/Era`` database row. +/// Unlike the CSV importers, this runs synchronously in the request rather than via +/// a background job. +// // Era Importer Structure Definition and functions // // Author: Richard J Hancock @@ -8,10 +14,14 @@ import Foundation import Vapor extension Importers { + /// The root element of the era XML file: a flat list of `` entries. struct Eras: Codable { var era: [Importers.Era] } + /// One `` element from the XML file. `end` is the last in-universe year of + /// the era; the era's start year is derived by the controller from the + /// previous era's end year rather than stored in the file. struct Era: Codable { var code: String var name: String @@ -23,6 +33,17 @@ extension Importers { } extension BattleTech.Era { + /// Finds the existing ``BattleTech/Era`` matching an imported era's `code`, or + /// builds a new (unsaved) one, applying the imported fields either way. The + /// caller is responsible for calling `save(on:)` on the result. + /// + /// - Parameters: + /// - importableEra: The parsed `` XML element to import. + /// - startYear: The start year to assign, computed by the caller from the + /// previous era's end year (eras are contiguous, sorted by end year). + /// - database: The `Database` to search for an existing matching era in. + /// - Returns: The existing era (updated in memory) or a new, unsaved era. + /// - Throws: Rethrows errors from the database lookup. static func findOrNew( importableEra: Importers.Era, startYear: Int, diff --git a/Sources/App/Importers/Faction+Import.swift b/Sources/App/Importers/Faction+Import.swift index cc4d299..bbe3bb4 100644 --- a/Sources/App/Importers/Faction+Import.swift +++ b/Sources/App/Importers/Faction+Import.swift @@ -1,3 +1,10 @@ +/// Defines the shape of the faction XML file uploaded to +/// `POST /battletech/factions/import` (handled by +/// ``BattleTech/FactionController/massCreate(req:)``) and the logic for turning +/// each parsed `` element (including its name-history and parent-faction +/// data) into ``BattleTech/Faction`` and ``BattleTech/FactionName`` database rows. +/// This runs synchronously in the request rather than via a background job. +// // Era Importer Structure Definition and functions // // Author: Richard J Hancock @@ -9,10 +16,16 @@ import Vapor import XMLCoder extension Importers { + /// The root element of the faction XML file: a flat list of `` + /// entries. struct Factions: Codable { let faction: [Importers.Faction] } + /// One `` element from the XML file, including its current name, + /// the years it has existed (as a comma-separated list of ranges), an + /// optional parent-faction key (or comma-separated keys for multiple + /// parents), and any historical name changes. struct Faction: Codable { let key: String let name: String @@ -26,6 +39,8 @@ extension Importers { var nameChange: [FactionNameChange]? } + /// One historical rename of a faction: the year it took effect, the new + /// name, and an optional image reference. struct FactionNameChange: Codable { let year: Int let value: String @@ -33,6 +48,9 @@ extension Importers { } } +/// Tells `XMLCoder` how to decode `` elements: `year` and `image` are +/// XML attributes, while the new name itself is the element's text content +/// (mapped to the empty-string coding key). extension Importers.FactionNameChange: DynamicNodeDecoding { enum CodingKeys: String, CodingKey { case year @@ -40,6 +58,11 @@ extension Importers.FactionNameChange: DynamicNodeDecoding { case value = "" } + /// Reports, per coding key, whether it should be decoded from an XML + /// attribute or from element content. + /// + /// - Parameter key: The coding key being decoded. + /// - Returns: `.attribute` for `year`/`image`, `.element` otherwise. static func nodeDecoding(for key: any CodingKey) -> XMLCoder.XMLDecoder.NodeDecoding { switch key { case CodingKeys.year, CodingKeys.image: @@ -51,6 +74,16 @@ extension Importers.FactionNameChange: DynamicNodeDecoding { } extension BattleTech.Faction { + /// Finds the existing ``BattleTech/Faction`` matching an imported faction's + /// `key`, or builds a new (unsaved) one, applying the imported fields either + /// way. The caller is responsible for calling `save(on:)` on the result. + /// + /// - Parameters: + /// - importableFaction: The parsed `` XML element to import. + /// - database: The `Database` to search for an existing matching faction in. + /// - Returns: The existing faction (updated in memory) or a new, unsaved + /// faction. + /// - Throws: Rethrows errors from the database lookup. static func findOrNew( importableFaction: Importers.Faction, on database: Database @@ -76,6 +109,17 @@ extension BattleTech.Faction { return foundFaction } + /// Links a faction to its parent faction(s) by attaching pivot rows for each + /// key in the imported faction's (possibly comma-separated) `parentFaction` + /// field. Called only after all factions have already been created/updated, + /// since a parent faction must exist in the database before it can be + /// attached. + /// + /// - Parameters: + /// - importableFaction: The parsed `` XML element whose parent + /// links should be applied. + /// - database: The `Database` to look up the faction and its parents in. + /// - Throws: Rethrows errors from the database lookups or attach operation. static func updateParent( importableFaction: Importers.Faction, on database: Database @@ -100,6 +144,16 @@ extension BattleTech.Faction { } } + /// Rebuilds this faction's name history from the imported data: clears any + /// existing ``BattleTech/FactionName`` rows, recreates one per year-range in + /// `importableFaction.years` using the faction's current name, then applies + /// any explicit historical renames via ``nameChangeUpdates(importableFaction:on:)``. + /// + /// - Parameters: + /// - importableFaction: The parsed `` XML element supplying the + /// name and year ranges. + /// - database: The `Database` to read/write faction-name rows on. + /// - Throws: Rethrows errors from the delete/create operations. func updateName( importableFaction: Importers.Faction, on database: Database @@ -107,6 +161,8 @@ extension BattleTech.Faction { try await self.$names.query(on: database).delete() let years = importableFaction.years.split(separator: ",", omittingEmptySubsequences: true) for range in years { + // Each comma-separated entry is either a single year ("3025") or a + // "start-end" range ("3025-3050"); parse out whichever bounds are present. let startAndEndYears = range.split(separator: "-", omittingEmptySubsequences: true) var startYear: Int? var endYear: Int? @@ -132,6 +188,19 @@ extension BattleTech.Faction { try await nameChangeUpdates(importableFaction: importableFaction, on: database) } + /// Applies any explicit `` entries on top of the name history + /// built by ``updateName(importableFaction:on:)``: for each rename year, either + /// updates the ``BattleTech/FactionName`` row already starting that year, or + /// splits the preceding name period so the new name starts on the correct + /// year. + /// + /// - Parameters: + /// - importableFaction: The parsed `` XML element supplying the + /// `nameChange` entries. + /// - database: The `Database` to read/write faction-name rows on. + /// - Throws: Rethrows errors from the database lookups or saves; force-unwraps + /// the previous name period, so malformed source data (a name change with no + /// prior name period) will crash. func nameChangeUpdates( importableFaction: Importers.Faction, on database: Database @@ -142,9 +211,12 @@ extension BattleTech.Faction { .filter(\.$startYear == nameChange.year) .first() { + // A name period already starts exactly on the change year; just rename it. foundName.name = nameChange.value try await foundName.save(on: database) } else { + // No period starts on this year: split the preceding period so it ends + // the year before, and insert a new period starting on the change year. let previousName = try await self.$names.query(on: database) .filter(\.$startYear < nameChange.year) .first()! @@ -167,6 +239,18 @@ extension BattleTech.Faction { } extension BattleTech.FactionName { + /// Finds the existing ``BattleTech/FactionName`` period for a faction starting + /// in `startYear`, updating it in place, or creates and saves a new one if + /// none exists. + /// + /// - Parameters: + /// - faction: The faction this name period belongs to. + /// - name: The faction's display name during this period. + /// - startYear: The first in-universe year this name applies, if known. + /// - endYear: The last in-universe year this name applies, if known. + /// - image: An optional image/flag reference for this name period. + /// - database: The `Database` to read/write the faction-name row on. + /// - Throws: Rethrows errors from the database lookup or save. static func findOrNew( faction: BattleTech.Faction, name: String, diff --git a/Sources/App/Importers/Importers.swift b/Sources/App/Importers/Importers.swift index f7baba6..95b78ab 100644 --- a/Sources/App/Importers/Importers.swift +++ b/Sources/App/Importers/Importers.swift @@ -1,7 +1,19 @@ +/// This file defines the shared ``Importers`` namespace used by every data-import +/// type in `Sources/App/Importers`. Those types parse the CSV/XML data exports +/// sourced from the MegaMek project (equipment/weapon/ammo stat sheets and +/// universe/era/faction data — see the sample files under `Tests/Resources` for +/// their shape) that an admin uploads to the `/import` routes, converting each +/// row/element into a typed struct that controllers and background `Job`s then +/// turn into database models. +// // Parent structure for Importers to allow namespace // with similar names with no collusion. // // Author: Richard J Hancock // Date: 2024/02/19 +/// Empty namespace type. Every importer (e.g. `Importers.AmmoCSVRow`, +/// `Importers.Era`, `Importers.Faction`) is declared as a nested type in an +/// extension of ``Importers``, purely to avoid name collisions between similarly +/// named CSV/XML row types across ammo, equipment, weapons, eras, and factions. struct Importers {} diff --git a/Sources/App/Importers/Weapon+Import.swift b/Sources/App/Importers/Weapon+Import.swift index a9ec35c..ac7506e 100644 --- a/Sources/App/Importers/Weapon+Import.swift +++ b/Sources/App/Importers/Weapon+Import.swift @@ -1,3 +1,10 @@ +/// Parses the weapon CSV file uploaded to `POST /battletech/weapons/import` +/// (handled by ``BattleTech/WeaponController/massCreate(req:)``) into typed rows. +/// Each row is dispatched as a ``WeaponImportJob`` payload, whose `dequeue` writes +/// it into the ``BattleTech/Weapon`` table. + +/// The column order of the weapon CSV file, used as raw-value indexes into each +/// row's array of string cells. extension Importers { enum WeaponHeaders: Int { case name @@ -34,18 +41,28 @@ extension Importers { } } +/// A single row of the weapon import CSV, exposed as typed accessors over the raw +/// array of string cells (indexed via ``Importers/WeaponHeaders``). Built by +/// ``BattleTech/WeaponController/massCreate(req:)`` for each CSV row and queued as +/// the payload for ``WeaponImportJob``. extension Importers { struct WeaponCSVRow: Codable { + /// Wraps one already-split CSV row (one string per column). init(row: [String]) { self.row = row } private let row: [String] + /// The weapon's display name. func name() -> String { row[Importers.WeaponHeaders.name.rawValue] } + /// The tech base (e.g. Inner Sphere, Clan, Mixed) as raw CSV text. func techBase() -> String { row[Importers.WeaponHeaders.techBase.rawValue] } + /// The raw, unsplit rules-reference cell (e.g. `"Rules Level/Unofficial"`). func rulesRaw() -> String { row[Importers.WeaponHeaders.rules.rawValue] } + /// The construction rules this weapon is legal under, split on `/` into + /// individual rule names (e.g. Tournament Legal, Standard, Advanced). func rules() -> [String] { row[Importers.WeaponHeaders.rules.rawValue].split( separator: "/", @@ -53,96 +70,139 @@ extension Importers { ).map(String.init) } + /// The tech rating letter code (e.g. `"D"`, `"F"`). func techRating() -> String { row[Importers.WeaponHeaders.techRating.rawValue] } + /// The static tech level string (e.g. `"Standard"`, `"Unofficial"`); used by + /// callers to skip unofficial rows before importing. func staticTechLevel() -> String { row[Importers.WeaponHeaders.staticTechLevel.rawValue] } + /// The in-universe year this weapon was introduced, or `nil` if the cell is + /// empty. func introductionDate() -> String? { let value = row[Importers.WeaponHeaders.introductionDate.rawValue] return value.count > 0 ? value : nil } + /// The in-universe year this weapon reached prototype status, or `nil` if + /// the cell is empty. func prototypeDate() -> String? { let value = row[Importers.WeaponHeaders.prototypeDate.rawValue] return value.count > 0 ? value : nil } + /// The in-universe year this weapon reached full production, or `nil` if + /// the cell is empty. func productionDate() -> String? { let value = row[Importers.WeaponHeaders.productionDate.rawValue] return value.count > 0 ? value : nil } + /// The in-universe year this weapon became common, or `nil` if the cell is + /// empty. func commonDate() -> String? { let value = row[Importers.WeaponHeaders.commonDate.rawValue] return value.count > 0 ? value : nil } + /// The in-universe year this weapon went extinct, or `nil` if the cell is + /// empty. func extinctionDate() -> String? { let value = row[Importers.WeaponHeaders.extinctionDate.rawValue] return value.count > 0 ? value : nil } + /// The in-universe year this weapon was reintroduced after extinction, or + /// `nil` if the cell is empty. func reIntroductionDate() -> String? { let value = row[Importers.WeaponHeaders.reIntroductionDate.rawValue] return value.count > 0 ? value : nil } + /// The weapon's weight in tons, parsed from the CSV cell (`0.0` if + /// unparsable). func tonnage() -> Double { Double(row[Importers.WeaponHeaders.tonnage.rawValue]) ?? 0.0 } + /// The number of critical slots this weapon occupies (`0` if unparsable). func criticalSlots() -> Int { Int(row[Importers.WeaponHeaders.criticalSlots.rawValue]) ?? 0 } + /// The in-universe C-bill cost (`0.0` if unparsable). func cost() -> Double { Double(row[Importers.WeaponHeaders.cost.rawValue]) ?? 0.0 } + /// The Battle Value contribution of this weapon (`0.0` if unparsable). func battleValue() -> Double { Double(row[Importers.WeaponHeaders.battleValue.rawValue]) ?? 0.0 } + /// The specific rulebook/page reference for this weapon. func rulesReference() -> String { row[Importers.WeaponHeaders.rulesReference.rawValue] } + /// The minimum effective range in hexes (`0` if unparsable). func minimalRange() -> Int { Int(row[Importers.WeaponHeaders.minimalRange.rawValue]) ?? 0 } + /// The short-range bracket distance in hexes (`0` if unparsable). func shortRange() -> Int { Int(row[Importers.WeaponHeaders.shortRange.rawValue]) ?? 0 } + /// The medium-range bracket distance in hexes (`0` if unparsable). func mediumRange() -> Int { Int(row[Importers.WeaponHeaders.mediumRange.rawValue]) ?? 0 } + /// The long-range bracket distance in hexes (`0` if unparsable). func longRange() -> Int { Int(row[Importers.WeaponHeaders.longRange.rawValue]) ?? 0 } + /// The extreme-range bracket distance in hexes (`0` if unparsable). func extremeRange() -> Int { Int(row[Importers.WeaponHeaders.extremeRange.rawValue]) ?? 0 } + /// The short-range bracket distance in hexes when fired underwater (`0` if + /// unparsable). func shortWaterRange() -> Int { Int(row[Importers.WeaponHeaders.shortWaterRange.rawValue]) ?? 0 } + /// The medium-range bracket distance in hexes when fired underwater (`0` if + /// unparsable). func mediumWaterRange() -> Int { Int(row[Importers.WeaponHeaders.mediumWaterRange.rawValue]) ?? 0 } + /// The long-range bracket distance in hexes when fired underwater (`0` if + /// unparsable). func longWaterRange() -> Int { Int(row[Importers.WeaponHeaders.longWaterRange.rawValue]) ?? 0 } + /// The extreme-range bracket distance in hexes when fired underwater (`0` + /// if unparsable). func extremeWaterRange() -> Int { Int(row[Importers.WeaponHeaders.extremeWaterRange.rawValue]) ?? 0 } + /// Damage dealt at minimal range (`0` if unparsable). func minimalDamage() -> Int { Int(row[Importers.WeaponHeaders.minimalDamage.rawValue]) ?? 0 } + /// Damage dealt at short range (`0` if unparsable). func shortDamage() -> Int { Int(row[Importers.WeaponHeaders.shortDamage.rawValue]) ?? 0 } + /// Damage dealt at medium range (`0` if unparsable). func mediumDamage() -> Int { Int(row[Importers.WeaponHeaders.mediumDamage.rawValue]) ?? 0 } + /// Damage dealt at long range (`0` if unparsable). func longDamage() -> Int { Int(row[Importers.WeaponHeaders.longDamage.rawValue]) ?? 0 } + /// Damage dealt at extreme range (`0` if unparsable). func extremeDamage() -> Int { Int(row[Importers.WeaponHeaders.extremeDamage.rawValue]) ?? 0 } + /// The alias cell with this weapon's own name stripped out, leaving only + /// the comma-separated alternate names. func rawAlias() -> String { row[Importers.EquipmentHeaders.alias.rawValue].replacingOccurrences(of: self.name(), with: "") } + /// The alternate/alias names for this weapon, split, deduplicated, and + /// trimmed of the primary name. func alias() -> [String] { self.rawAlias().split( separator: ",", diff --git a/Sources/App/Jobs/AmmoImportJob.swift b/Sources/App/Jobs/AmmoImportJob.swift index 41197ee..28ff8ef 100644 --- a/Sources/App/Jobs/AmmoImportJob.swift +++ b/Sources/App/Jobs/AmmoImportJob.swift @@ -9,9 +9,24 @@ import Foundation import Queues import Vapor +/// A background `Job` (from Vapor's Queues package) that turns a single parsed +/// ammo CSV row into a database row. ``BattleTech/AmmoController/massCreate(req:)`` +/// dispatches one of these per row of an uploaded ammo CSV file rather than writing +/// rows synchronously, so a large import doesn't block the HTTP request or risk a +/// timeout; a worker (started by ``JobSchedules``) dequeues and runs them. struct AmmoImportJob: AsyncJob { + /// The data handed to this job when it is dispatched: one row of an ammo CSV + /// file, already split into typed accessors. typealias Payload = Importers.AmmoCSVRow + /// Executes the job: finds an existing ``BattleTech/Ammo`` row matching the + /// CSV row (by name) or creates a new one, then saves it. + /// + /// - Parameters: + /// - context: The `QueueContext` providing access to the application (and its + /// database) while the job runs. + /// - payload: The ammo CSV row to import. + /// - Throws: Rethrows any error from finding/creating or saving the ``BattleTech/Ammo`` row. func dequeue(_ context: QueueContext, _ payload: Importers.AmmoCSVRow) async throws { _ = try await BattleTech.Ammo.findOrCreate( csvRow: payload, @@ -19,6 +34,12 @@ struct AmmoImportJob: AsyncJob { ) } + /// Determines the delay before retrying a failed job attempt. + /// + /// - Parameter attempt: The retry attempt number (unused; delay is randomized + /// regardless of attempt count). + /// - Returns: A random delay between 15 and 60 seconds, to spread out retries + /// across a batch instead of retrying every failed row at once. public func nextRetryIn(attempt: Int) -> Int { return Int.random(in: 15...60) } diff --git a/Sources/App/Jobs/EquipmentImportJob.swift b/Sources/App/Jobs/EquipmentImportJob.swift index 2284025..12f1d67 100644 --- a/Sources/App/Jobs/EquipmentImportJob.swift +++ b/Sources/App/Jobs/EquipmentImportJob.swift @@ -9,9 +9,24 @@ import Foundation import Queues import Vapor +/// A background `Job` (from Vapor's Queues package) that turns a single parsed +/// equipment CSV row into a database row. ``BattleTech/EquipmentController/massCreate(req:)`` +/// dispatches one of these per row of an uploaded equipment CSV file rather than +/// writing rows synchronously; a worker (started by ``JobSchedules``) dequeues and +/// runs them. struct EquipmentImportJob: AsyncJob { + /// The data handed to this job when it is dispatched: one row of an equipment + /// CSV file, already split into typed accessors. typealias Payload = Importers.EquipmentCSVRow + /// Executes the job: finds an existing ``BattleTech/Equipment`` row matching + /// the CSV row (by name) or creates a new one, then saves it. + /// + /// - Parameters: + /// - context: The `QueueContext` providing access to the application (and its + /// database) while the job runs. + /// - payload: The equipment CSV row to import. + /// - Throws: Rethrows any error from finding/creating or saving the ``BattleTech/Equipment`` row. func dequeue(_ context: QueueContext, _ payload: Importers.EquipmentCSVRow) async throws { _ = try await BattleTech.Equipment.findOrCreate( csvRow: payload, @@ -19,6 +34,12 @@ struct EquipmentImportJob: AsyncJob { ) } + /// Determines the delay before retrying a failed job attempt. + /// + /// - Parameter attempt: The retry attempt number (unused; delay is randomized + /// regardless of attempt count). + /// - Returns: A random delay between 15 and 60 seconds, to spread out retries + /// across a batch instead of retrying every failed row at once. public func nextRetryIn(attempt: Int) -> Int { return Int.random(in: 15...60) } diff --git a/Sources/App/Jobs/SendEMailPayload.swift b/Sources/App/Jobs/SendEMailPayload.swift new file mode 100644 index 0000000..5274395 --- /dev/null +++ b/Sources/App/Jobs/SendEMailPayload.swift @@ -0,0 +1,61 @@ +// +// SendEMailPayload.swift +// mul-api +// +// Created by Richard Hancock on 5/9/25. +// + + +import Queues +import SendGrid +import Vapor + +/// The serializable payload dispatched to the job queue for an outgoing email, +/// built by ``EmailHandler`` from a rendered Leaf template. Carries everything +/// needed to construct a SendGrid email without depending on a live `Request`. +struct SendEMailPayload: Codable { + /// Recipients and per-recipient substitution data/subject for the email. + var personalizations: [Personalization<[String: String]>] + /// The body content (e.g. the rendered plain-text template) and its MIME type. + var content: [EmailContent] +} + +/// A background `Job` (from Vapor's Queues package) that actually sends an email +/// via the SendGrid API. Kept off the request thread so an HTTP handler doesn't +/// have to wait on an external mail provider; dispatched by ``EmailHandler/send(emailConfig:emailContent:on:)``. +struct SendEmailJob: AsyncJob { + /// The data handed to this job when it is dispatched: the recipients, subject, + /// and body content to send. + typealias Payload = SendEMailPayload + + /// The fixed sender identity used for all outgoing mail from this service. + let fromEmail = EmailAddress( + email: "noreply@fasa.dev", + name: "FASA Central API Service" + ) + + /// Executes the job: builds a `SendGridEmail` from the payload and sends it + /// through the app's SendGrid client. SendGrid-specific failures are logged + /// and swallowed rather than rethrown, so a bad/rejected email does not cause + /// the job to endlessly retry. + /// + /// - Parameters: + /// - context: The `QueueContext` providing access to the application (and + /// its configured SendGrid client) while the job runs. + /// - payload: The recipients, subject, and content to send. + /// - Throws: Does not rethrow `SendGridError`s (they are logged instead); other + /// errors propagate normally. + func dequeue(_ context: Queues.QueueContext, _ payload: SendEMailPayload) async throws { + let email = SendGridEmail( + personalizations: payload.personalizations, + from: fromEmail, + content: payload.content + ) + + do { + _ = try await context.application.sendgrid.client.send(email: email) + } catch let error as SendGridError { + print(error.localizedDescription) + } + } +} diff --git a/Sources/App/Jobs/WeaponImportJob.swift b/Sources/App/Jobs/WeaponImportJob.swift index 91abdc9..3c43a7e 100644 --- a/Sources/App/Jobs/WeaponImportJob.swift +++ b/Sources/App/Jobs/WeaponImportJob.swift @@ -9,9 +9,24 @@ import Foundation import Queues import Vapor +/// A background `Job` (from Vapor's Queues package) that turns a single parsed +/// weapon CSV row into a database row. ``BattleTech/WeaponController/massCreate(req:)`` +/// dispatches one of these per row of an uploaded weapon CSV file rather than +/// writing rows synchronously; a worker (started by ``JobSchedules``) dequeues and +/// runs them. struct WeaponImportJob: AsyncJob { + /// The data handed to this job when it is dispatched: one row of a weapon CSV + /// file, already split into typed accessors. typealias Payload = Importers.WeaponCSVRow + /// Executes the job: finds an existing ``BattleTech/Weapon`` row matching the + /// CSV row (by name) or creates a new one, then saves it. + /// + /// - Parameters: + /// - context: The `QueueContext` providing access to the application (and its + /// database) while the job runs. + /// - payload: The weapon CSV row to import. + /// - Throws: Rethrows any error from finding/creating or saving the ``BattleTech/Weapon`` row. func dequeue(_ context: QueueContext, _ payload: Importers.WeaponCSVRow) async throws { _ = try await BattleTech.Weapon.findOrCreate( csvRow: payload, @@ -19,6 +34,12 @@ struct WeaponImportJob: AsyncJob { ) } + /// Determines the delay before retrying a failed job attempt. + /// + /// - Parameter attempt: The retry attempt number (unused; delay is randomized + /// regardless of attempt count). + /// - Returns: A random delay between 15 and 60 seconds, to spread out retries + /// across a batch instead of retrying every failed row at once. public func nextRetryIn(attempt: Int) -> Int { return Int.random(in: 15...60) } diff --git a/Sources/App/Migrations/2024-04-09-AddStartYearToEra.swift b/Sources/App/Migrations/2024-04-09-AddStartYearToEra.swift deleted file mode 100644 index 2a5503c..0000000 --- a/Sources/App/Migrations/2024-04-09-AddStartYearToEra.swift +++ /dev/null @@ -1,33 +0,0 @@ -// -// AddStartYearToEras.swift -// -// Adds the Start Year to Era -// -// Author: Richard J Hancock -// Date: 2024/02/12 -// - -import Fluent -import FluentSQL - -extension BattleTech { - struct AddStartYearToEras: AsyncMigration { - func prepare(on database: any Database) async throws { - try await database.schema(for: BattleTech.Era.self) - .field(BattleTech.Era.V20240409.startYear, .int) - .update() - } - - func revert(on database: any Database) async throws { - try await database.schema(for: BattleTech.Era.self) - .deleteField(BattleTech.Era.V20240409.startYear) - .update() - } - } -} - -extension BattleTech.Era { - enum V20240409 { - static let startYear = FieldKey(stringLiteral: "start_year") - } -} diff --git a/Sources/App/Migrations/2024-04-15-AddImageToFactionName.swift b/Sources/App/Migrations/2024-04-15-AddImageToFactionName.swift deleted file mode 100644 index 3790353..0000000 --- a/Sources/App/Migrations/2024-04-15-AddImageToFactionName.swift +++ /dev/null @@ -1,33 +0,0 @@ -// -// AddImageToFactionName.swift -// -// Adds the Image to Faction Name -// -// Author: Richard J Hancock -// Date: 2024/04/15 -// - -import Fluent -import FluentSQL - -extension BattleTech { - struct AddImageToFactionName: AsyncMigration { - func prepare(on database: any Database) async throws { - try await database.schema(for: BattleTech.FactionName.self) - .field(BattleTech.FactionName.V20240415.image, .string) - .update() - } - - func revert(on database: any Database) async throws { - try await database.schema(for: BattleTech.FactionName.self) - .deleteField(BattleTech.FactionName.V20240415.image) - .update() - } - } -} - -extension BattleTech.FactionName { - enum V20240415 { - static let image = FieldKey(stringLiteral: "image_name") - } -} diff --git a/Sources/App/Migrations/2024-02-12-CreateEras.swift b/Sources/App/Migrations/BattleTech/2024-02-12-CreateEras.swift similarity index 59% rename from Sources/App/Migrations/2024-02-12-CreateEras.swift rename to Sources/App/Migrations/BattleTech/2024-02-12-CreateEras.swift index 7a8e336..ddc633c 100644 --- a/Sources/App/Migrations/2024-02-12-CreateEras.swift +++ b/Sources/App/Migrations/BattleTech/2024-02-12-CreateEras.swift @@ -1,17 +1,25 @@ -// -// CreateEras.swift -// -// Creates the Eras model based upon the XML from MegaMek -// -// Author: Richard J Hancock -// Date: 2024/02/12 -// +/// CreateEras.swift +/// +/// Fluent migration that creates the `eras` table, storing BattleTech's game-timeline eras +/// (e.g. Succession Wars, Clan Invasion) based on data sourced from MegaMek's XML. +/// +/// Author: Richard J Hancock +/// Date: 2024/02/12 import Fluent import FluentSQL extension BattleTech { + /// Creates the `eras` table. + /// + /// This is a Fluent ``AsyncMigration``: a versioned, ordered database-schema change. + /// ``prepare(on:)`` applies the change (run when migrating up) and ``revert(on:)`` undoes it + /// (run when rolling back). Vapor tracks which migrations have already run so each one is + /// applied at most once. struct CreateEras: AsyncMigration { + /// Creates the `eras` table with its columns, uniqueness constraints, and timestamps. + /// - Parameter database: The database connection to apply the schema change to. + /// - Throws: An error if the schema change fails. func prepare(on database: any Database) async throws { try await database.schema(for: BattleTech.Era.self) .id() @@ -32,6 +40,9 @@ extension BattleTech { .create() } + /// Drops the `eras` table. + /// - Parameter database: The database connection to apply the schema change to. + /// - Throws: An error if the schema change fails. func revert(on database: any Database) async throws { try await database.schema(for: BattleTech.Era.self).delete() } @@ -39,6 +50,10 @@ extension BattleTech { } extension BattleTech.Era { + /// Column-name constants (``FieldKey``s) for the `eras` table as of the 2024-02-12 schema + /// version. Keeping these fixed per schema version means a later rename of a Swift property + /// on ``BattleTech/Era`` won't silently change, or break, the actual database column name + /// and existing data. enum V20240212 { static let schemaName = "eras" static let spaceName = "battletech" diff --git a/Sources/App/Migrations/2024-03-16-CreateFactionNames.swift b/Sources/App/Migrations/BattleTech/2024-03-16-CreateFactionNames.swift similarity index 55% rename from Sources/App/Migrations/2024-03-16-CreateFactionNames.swift rename to Sources/App/Migrations/BattleTech/2024-03-16-CreateFactionNames.swift index df4ba5b..9e005b3 100644 --- a/Sources/App/Migrations/2024-03-16-CreateFactionNames.swift +++ b/Sources/App/Migrations/BattleTech/2024-03-16-CreateFactionNames.swift @@ -1,17 +1,26 @@ -// -// CreateFactionNames.swift -// -// Creates the Faction Names model based upon the XML from MegaMek -// -// Author: Richard J Hancock -// Date: 2024/03/16 -// +/// CreateFactionNames.swift +/// +/// Fluent migration that creates the `faction_names` table, storing the (possibly multiple, +/// date-ranged) names a faction has used over BattleTech's history, based on data sourced from +/// MegaMek's XML. +/// +/// Author: Richard J Hancock +/// Date: 2024/03/16 import Fluent import FluentSQL extension BattleTech { + /// Creates the `faction_names` table, which references `factions` via a `faction_id` + /// foreign key. + /// + /// Like every migration in this directory, this is a Fluent ``AsyncMigration``: + /// ``prepare(on:)`` applies the schema change and ``revert(on:)`` undoes it. Fluent tracks + /// which migrations have already run so each applies at most once. struct CreateFactionNames: AsyncMigration { + /// Creates the `faction_names` table. + /// - Parameter database: The database connection to apply the schema change to. + /// - Throws: An error if the schema change fails. func prepare(on database: any Database) async throws { try await database.schema(for: BattleTech.FactionName.self) .id() @@ -32,6 +41,9 @@ extension BattleTech { .create() } + /// Drops the `faction_names` table. + /// - Parameter database: The database connection to apply the schema change to. + /// - Throws: An error if the schema change fails. func revert(on database: any Database) async throws { try await database.schema(for: BattleTech.FactionName.self).delete() } @@ -39,6 +51,9 @@ extension BattleTech { } extension BattleTech.FactionName { + /// Column-name constants (``FieldKey``s) for the `faction_names` table as of the + /// 2024-03-16 schema version — a stable, versioned record of column names independent of + /// the Swift property names on ``BattleTech/FactionName``. enum V20240316 { static let schemaName = "faction_names" static let spaceName = "battletech" diff --git a/Sources/App/Migrations/2024-03-16-CreateFactionSubfactionPivot.swift b/Sources/App/Migrations/BattleTech/2024-03-16-CreateFactionSubfactionPivot.swift similarity index 58% rename from Sources/App/Migrations/2024-03-16-CreateFactionSubfactionPivot.swift rename to Sources/App/Migrations/BattleTech/2024-03-16-CreateFactionSubfactionPivot.swift index 8ff7ab5..f7b4281 100644 --- a/Sources/App/Migrations/2024-03-16-CreateFactionSubfactionPivot.swift +++ b/Sources/App/Migrations/BattleTech/2024-03-16-CreateFactionSubfactionPivot.swift @@ -1,17 +1,24 @@ -// -// CreateFactionSubfactionPivot.swift -// -// Creates the Factions model based upon the XML from MegaMek -// -// Author: Richard J Hancock -// Date: 2024/03/16 -// +/// CreateFactionSubfactionPivot.swift +/// +/// Fluent migration that creates the `faction_subfaction_pivot` table, the many-to-many join +/// table linking a faction to the other factions that are its sub-factions. +/// +/// Author: Richard J Hancock +/// Date: 2024/03/16 import Fluent import FluentSQL extension BattleTech { + /// Creates the `faction_subfaction_pivot` table. + /// + /// A "pivot" migration creates a join table for a many-to-many relationship instead of a + /// table for a standalone model — here, between factions and their sub-factions. Both + /// foreign keys reference `factions.id` since a sub-faction is itself a ``BattleTech/Faction``. struct CreateFactionSubfactionPivot: AsyncMigration { + /// Creates the `faction_subfaction_pivot` table. + /// - Parameter database: The database connection to apply the schema change to. + /// - Throws: An error if the schema change fails. func prepare(on database: any Database) async throws { try await database.schema(for: BattleTech.FactionSubfactionPivot.self) .id() @@ -33,6 +40,9 @@ extension BattleTech { .create() } + /// Drops the `faction_subfaction_pivot` table. + /// - Parameter database: The database connection to apply the schema change to. + /// - Throws: An error if the schema change fails. func revert(on database: any Database) async throws { try await database.schema(for: BattleTech.FactionSubfactionPivot.self).delete() } @@ -40,6 +50,8 @@ extension BattleTech { } extension BattleTech.FactionSubfactionPivot { + /// Column-name constants (``FieldKey``s) for the `faction_subfaction_pivot` table, version + /// 2024-03-16. enum V20240316 { static let schemaName = "faction_subfaction_pivot" static let spaceName = "battletech" diff --git a/Sources/App/Migrations/2024-03-16-CreateFactions.swift b/Sources/App/Migrations/BattleTech/2024-03-16-CreateFactions.swift similarity index 71% rename from Sources/App/Migrations/2024-03-16-CreateFactions.swift rename to Sources/App/Migrations/BattleTech/2024-03-16-CreateFactions.swift index 59f52fa..107fcdf 100644 --- a/Sources/App/Migrations/2024-03-16-CreateFactions.swift +++ b/Sources/App/Migrations/BattleTech/2024-03-16-CreateFactions.swift @@ -1,17 +1,20 @@ -// -// CreateFactions.swift -// -// Creates the Factions model based upon the XML from MegaMek -// -// Author: Richard J Hancock -// Date: 2024/03/16 -// +/// CreateFactions.swift +/// +/// Fluent migration that creates the `factions` table, storing BattleTech's playable factions +/// (e.g. House Davion, Clan Wolf), based on data sourced from MegaMek's XML. +/// +/// Author: Richard J Hancock +/// Date: 2024/03/16 import Fluent import FluentSQL extension BattleTech { + /// Creates the `factions` table. struct CreateFactions: AsyncMigration { + /// Creates the `factions` table. + /// - Parameter database: The database connection to apply the schema change to. + /// - Throws: An error if the schema change fails. func prepare(on database: any Database) async throws { try await database.schema(for: BattleTech.Faction.self) .id() @@ -29,6 +32,9 @@ extension BattleTech { .create() } + /// Drops the `factions` table. + /// - Parameter database: The database connection to apply the schema change to. + /// - Throws: An error if the schema change fails. func revert(on database: any Database) async throws { try await database.schema(for: BattleTech.Faction.self).delete() } @@ -36,6 +42,7 @@ extension BattleTech { } extension BattleTech.Faction { + /// Column-name constants (``FieldKey``s) for the `factions` table, version 2024-03-16. enum V20240316 { static let schemaName = "factions" static let spaceName = "battletech" diff --git a/Sources/App/Migrations/2024-03-16-CreateRuleWeaponPivot.swift b/Sources/App/Migrations/BattleTech/2024-03-16-CreateRuleWeaponPivot.swift similarity index 63% rename from Sources/App/Migrations/2024-03-16-CreateRuleWeaponPivot.swift rename to Sources/App/Migrations/BattleTech/2024-03-16-CreateRuleWeaponPivot.swift index 8942e29..eb70c85 100644 --- a/Sources/App/Migrations/2024-03-16-CreateRuleWeaponPivot.swift +++ b/Sources/App/Migrations/BattleTech/2024-03-16-CreateRuleWeaponPivot.swift @@ -1,17 +1,21 @@ -// -// CreateRuleWeaponPivot.swift -// -// Create the pivot for Rules with Weapons -// -// Author: Richard J Hancock -// Date: 2024/03/16 -// +/// CreateRuleWeaponPivot.swift +/// +/// Fluent migration that creates the `rule_weapon_pivot` table, the join table between weapons +/// and the rules that govern them. +/// +/// Author: Richard J Hancock +/// Date: 2024/03/16 import Fluent import FluentSQL extension BattleTech { + /// Creates the `rule_weapon_pivot` table — the many-to-many join table between + /// ``BattleTech/Rule`` and ``BattleTech/Weapon``. struct CreateRuleWeaponPivot: AsyncMigration { + /// Creates the `rule_weapon_pivot` table. + /// - Parameter database: The database connection to apply the schema change to. + /// - Throws: An error if the schema change fails. func prepare(on database: any Database) async throws { try await database.schema(for: BattleTech.RuleWeaponPivot.self) .id() @@ -33,6 +37,9 @@ extension BattleTech { .create() } + /// Drops the `rule_weapon_pivot` table. + /// - Parameter database: The database connection to apply the schema change to. + /// - Throws: An error if the schema change fails. func revert(on database: any Database) async throws { try await database.schema(for: BattleTech.RuleWeaponPivot.self).delete() } @@ -40,6 +47,8 @@ extension BattleTech { } extension BattleTech.RuleWeaponPivot { + /// Column-name constants (``FieldKey``s) for the `rule_weapon_pivot` table, version + /// 2024-03-16. enum V20240316 { static let schemaName = "rule_weapon_pivot" static let spaceName = "battletech" diff --git a/Sources/App/Migrations/2024-03-16-CreateRules.swift b/Sources/App/Migrations/BattleTech/2024-03-16-CreateRules.swift similarity index 52% rename from Sources/App/Migrations/2024-03-16-CreateRules.swift rename to Sources/App/Migrations/BattleTech/2024-03-16-CreateRules.swift index dd58077..bf64fe8 100644 --- a/Sources/App/Migrations/2024-03-16-CreateRules.swift +++ b/Sources/App/Migrations/BattleTech/2024-03-16-CreateRules.swift @@ -1,17 +1,20 @@ -// -// CreateRule.swift -// -// Creates the Rule model. Data Imported/Updated via data Import -// -// Author: Richard J Hancock -// Date: 2024/03/16 -// +/// CreateRule.swift +/// +/// Fluent migration that creates the `rules` table, storing named BattleTech rulebook +/// references. Row data itself is populated/updated via a separate data-import process. +/// +/// Author: Richard J Hancock +/// Date: 2024/03/16 import Fluent import FluentSQL extension BattleTech { + /// Creates the `rules` table. struct CreateRules: AsyncMigration { + /// Creates the `rules` table. + /// - Parameter database: The database connection to apply the schema change to. + /// - Throws: An error if the schema change fails. func prepare(on database: any Database) async throws { try await database.schema(for: BattleTech.Rule.self) .id() @@ -20,6 +23,9 @@ extension BattleTech { .create() } + /// Drops the `rules` table. + /// - Parameter database: The database connection to apply the schema change to. + /// - Throws: An error if the schema change fails. func revert(on database: any Database) async throws { try await database.schema(for: BattleTech.Rule.self).delete() } @@ -27,6 +33,7 @@ extension BattleTech { } extension BattleTech.Rule { + /// Column-name constants (``FieldKey``s) for the `rules` table, version 2024-03-16. enum V20240316 { static let schemaName = "rules" static let spaceName = "battletech" diff --git a/Sources/App/Migrations/2024-03-16-CreateTechBase.swift b/Sources/App/Migrations/BattleTech/2024-03-16-CreateTechBase.swift similarity index 50% rename from Sources/App/Migrations/2024-03-16-CreateTechBase.swift rename to Sources/App/Migrations/BattleTech/2024-03-16-CreateTechBase.swift index ab665a0..b1a25b5 100644 --- a/Sources/App/Migrations/2024-03-16-CreateTechBase.swift +++ b/Sources/App/Migrations/BattleTech/2024-03-16-CreateTechBase.swift @@ -1,17 +1,21 @@ -// -// CreateTechBase.swift -// -// Creates the Tech Base model. Data Imported/Updated via data Import -// -// Author: Richard J Hancock -// Date: 2024/03/16 -// +/// CreateTechBase.swift +/// +/// Fluent migration that creates the `tech_base` table, storing tech-base lookup values (e.g. +/// Inner Sphere, Clan) referenced by weapons, ammo, and equipment. Row data itself is +/// populated/updated via a separate data-import process. +/// +/// Author: Richard J Hancock +/// Date: 2024/03/16 import Fluent import FluentSQL extension BattleTech { + /// Creates the `tech_base` table. struct CreateTechBase: AsyncMigration { + /// Creates the `tech_base` table. + /// - Parameter database: The database connection to apply the schema change to. + /// - Throws: An error if the schema change fails. func prepare(on database: any Database) async throws { try await database.schema(for: BattleTech.TechBase.self) .id() @@ -20,6 +24,9 @@ extension BattleTech { .create() } + /// Drops the `tech_base` table. + /// - Parameter database: The database connection to apply the schema change to. + /// - Throws: An error if the schema change fails. func revert(on database: any Database) async throws { try await database.schema(for: BattleTech.TechBase.self).delete() } @@ -27,6 +34,7 @@ extension BattleTech { } extension BattleTech.TechBase { + /// Column-name constants (``FieldKey``s) for the `tech_base` table, version 2024-03-16. enum V20240316 { static let schemaName = "tech_base" static let spaceName = "battletech" diff --git a/Sources/App/Migrations/2024-03-16-CreateTechLevel.swift b/Sources/App/Migrations/BattleTech/2024-03-16-CreateTechLevel.swift similarity index 50% rename from Sources/App/Migrations/2024-03-16-CreateTechLevel.swift rename to Sources/App/Migrations/BattleTech/2024-03-16-CreateTechLevel.swift index d3c8aa2..2086452 100644 --- a/Sources/App/Migrations/2024-03-16-CreateTechLevel.swift +++ b/Sources/App/Migrations/BattleTech/2024-03-16-CreateTechLevel.swift @@ -1,17 +1,21 @@ -// -// CreateTechLevel.swift -// -// Creates the Tech Base model. Data Imported/Updated via data Import -// -// Author: Richard J Hancock -// Date: 2024/03/16 -// +/// CreateTechLevel.swift +/// +/// Fluent migration that creates the `tech_level` table, storing tech-level lookup values (e.g. +/// Introductory, Standard, Advanced) referenced by weapons, ammo, and equipment. Row data itself +/// is populated/updated via a separate data-import process. +/// +/// Author: Richard J Hancock +/// Date: 2024/03/16 import Fluent import FluentSQL extension BattleTech { + /// Creates the `tech_level` table. struct CreateTechLevel: AsyncMigration { + /// Creates the `tech_level` table. + /// - Parameter database: The database connection to apply the schema change to. + /// - Throws: An error if the schema change fails. func prepare(on database: any Database) async throws { try await database.schema(for: BattleTech.TechLevel.self) .id() @@ -20,6 +24,9 @@ extension BattleTech { .create() } + /// Drops the `tech_level` table. + /// - Parameter database: The database connection to apply the schema change to. + /// - Throws: An error if the schema change fails. func revert(on database: any Database) async throws { try await database.schema(for: BattleTech.TechLevel.self).delete() } @@ -27,6 +34,7 @@ extension BattleTech { } extension BattleTech.TechLevel { + /// Column-name constants (``FieldKey``s) for the `tech_level` table, version 2024-03-16. enum V20240316 { static let schemaName = "tech_level" static let spaceName = "battletech" diff --git a/Sources/App/Migrations/2024-03-16-CreateWeapons.swift b/Sources/App/Migrations/BattleTech/2024-03-16-CreateWeapons.swift similarity index 88% rename from Sources/App/Migrations/2024-03-16-CreateWeapons.swift rename to Sources/App/Migrations/BattleTech/2024-03-16-CreateWeapons.swift index 802bdf7..4718965 100644 --- a/Sources/App/Migrations/2024-03-16-CreateWeapons.swift +++ b/Sources/App/Migrations/BattleTech/2024-03-16-CreateWeapons.swift @@ -1,17 +1,21 @@ -// -// CreateWeapons.swift -// -// Creates the Weapons model for CSV Import -// -// Author: Richard J Hancock -// Date: 2024/03/16 -// +/// CreateWeapons.swift +/// +/// Fluent migration that creates the `weapons` table, storing BattleTech weapon data (ranges, +/// damage, tonnage, tech base/level, etc.) for CSV import. +/// +/// Author: Richard J Hancock +/// Date: 2024/03/16 import Fluent import FluentSQL extension BattleTech { + /// Creates the `weapons` table. struct CreateWeapons: AsyncMigration { + /// Creates the `weapons` table with its columns, foreign keys to `tech_base`/ + /// `tech_level`, and uniqueness constraint. + /// - Parameter database: The database connection to apply the schema change to. + /// - Throws: An error if the schema change fails. func prepare(on database: any Database) async throws { try await database.schema(for: BattleTech.Weapon.self) .id() @@ -66,6 +70,9 @@ extension BattleTech { .create() } + /// Drops the `weapons` table. + /// - Parameter database: The database connection to apply the schema change to. + /// - Throws: An error if the schema change fails. func revert(on database: any Database) async throws { try await database.schema(for: BattleTech.Weapon.self).delete() } @@ -73,6 +80,7 @@ extension BattleTech { } extension BattleTech.Weapon { + /// Column-name constants (``FieldKey``s) for the `weapons` table, version 2024-03-16. enum V20240316 { static let schemaName = "weapons" static let spaceName = "battletech" diff --git a/Sources/App/Migrations/2024-03-23-CreateWeaponAlias.swift b/Sources/App/Migrations/BattleTech/2024-03-23-CreateWeaponAlias.swift similarity index 62% rename from Sources/App/Migrations/2024-03-23-CreateWeaponAlias.swift rename to Sources/App/Migrations/BattleTech/2024-03-23-CreateWeaponAlias.swift index 01411fe..3d6702c 100644 --- a/Sources/App/Migrations/2024-03-23-CreateWeaponAlias.swift +++ b/Sources/App/Migrations/BattleTech/2024-03-23-CreateWeaponAlias.swift @@ -1,17 +1,21 @@ -// -// CreateWeaponAlias.swift -// -// Creates the Weapon Alias model. Data Imported/Updated via data Import -// -// Author: Richard J Hancock -// Date: 2024/03/23 -// +/// CreateWeaponAlias.swift +/// +/// Fluent migration that creates the `weapon_alias` table, storing alternate/historical names +/// for a weapon. Row data itself is populated/updated via a separate data-import process. +/// +/// Author: Richard J Hancock +/// Date: 2024/03/23 import Fluent import FluentSQL extension BattleTech { + /// Creates the `weapon_alias` table, which references `weapons` via a required + /// `weapon_id` foreign key. struct CreateWeaponAlias: AsyncMigration { + /// Creates the `weapon_alias` table. + /// - Parameter database: The database connection to apply the schema change to. + /// - Throws: An error if the schema change fails. func prepare(on database: any Database) async throws { try await database.schema(for: BattleTech.WeaponAlias.self) .id() @@ -34,6 +38,9 @@ extension BattleTech { .create() } + /// Drops the `weapon_alias` table. + /// - Parameter database: The database connection to apply the schema change to. + /// - Throws: An error if the schema change fails. func revert(on database: any Database) async throws { try await database.schema(for: BattleTech.WeaponAlias.self).delete() } @@ -41,6 +48,7 @@ extension BattleTech { } extension BattleTech.WeaponAlias { + /// Column-name constants (``FieldKey``s) for the `weapon_alias` table, version 2024-03-23. enum V20240323 { static let schemaName = "weapon_alias" static let spaceName = "battletech" diff --git a/Sources/App/Migrations/2024-03-27-CreateAmmo.swift b/Sources/App/Migrations/BattleTech/2024-03-27-CreateAmmo.swift similarity index 84% rename from Sources/App/Migrations/2024-03-27-CreateAmmo.swift rename to Sources/App/Migrations/BattleTech/2024-03-27-CreateAmmo.swift index f8fb435..69e847d 100644 --- a/Sources/App/Migrations/2024-03-27-CreateAmmo.swift +++ b/Sources/App/Migrations/BattleTech/2024-03-27-CreateAmmo.swift @@ -1,17 +1,22 @@ -// -// CreateAmmo.swift -// -// Creates the Ammo model for CSV Import -// -// Author: Richard J Hancock -// Date: 2024/03/27 -// +/// CreateAmmo.swift +/// +/// Fluent migration that creates the `ammo` table, storing BattleTech ammunition data (mirrors +/// the `weapons` table's shape, plus ammo-specific fields like shots and munition type) for CSV +/// import. +/// +/// Author: Richard J Hancock +/// Date: 2024/03/27 import Fluent import FluentSQL extension BattleTech { + /// Creates the `ammo` table. struct CreateAmmo: AsyncMigration { + /// Creates the `ammo` table with its columns, foreign keys to `tech_base`/`tech_level`/ + /// `munition_type`, and uniqueness constraint. + /// - Parameter database: The database connection to apply the schema change to. + /// - Throws: An error if the schema change fails. func prepare(on database: any Database) async throws { try await database.schema(for: BattleTech.Ammo.self) .id() @@ -65,6 +70,9 @@ extension BattleTech { .create() } + /// Drops the `ammo` table. + /// - Parameter database: The database connection to apply the schema change to. + /// - Throws: An error if the schema change fails. func revert(on database: any Database) async throws { try await database.schema(for: BattleTech.Ammo.self).delete() } @@ -72,6 +80,7 @@ extension BattleTech { } extension BattleTech.Ammo { + /// Column-name constants (``FieldKey``s) for the `ammo` table, version 2024-03-27. enum V20240327 { static let schemaName = "ammo" static let spaceName = "battletech" @@ -101,7 +110,7 @@ extension BattleTech.Ammo { static let ammoRatio = FieldKey(stringLiteral: "ammo_ratio") static let isCapital = FieldKey(stringLiteral: "is_capital") static let kilogramPerShot = FieldKey(stringLiteral: "kilogram_per_shot") - static let aeroUse = FieldKey(stringLiteral: "FieldKey") + static let aeroUse = FieldKey(stringLiteral: "aero_use") static let publishedAt = FieldKey(stringLiteral: "published_at") static let createdAt = FieldKey(stringLiteral: "created_at") diff --git a/Sources/App/Migrations/2024-03-27-CreateAmmoAlias.swift b/Sources/App/Migrations/BattleTech/2024-03-27-CreateAmmoAlias.swift similarity index 61% rename from Sources/App/Migrations/2024-03-27-CreateAmmoAlias.swift rename to Sources/App/Migrations/BattleTech/2024-03-27-CreateAmmoAlias.swift index 9d92db8..120d78c 100644 --- a/Sources/App/Migrations/2024-03-27-CreateAmmoAlias.swift +++ b/Sources/App/Migrations/BattleTech/2024-03-27-CreateAmmoAlias.swift @@ -1,17 +1,21 @@ -// -// CreateWeaponAlias.swift -// -// Creates the Weapon Alias model. Data Imported/Updated via data Import -// -// Author: Richard J Hancock -// Date: 2024/03/23 -// +/// CreateAmmoAlias.swift +/// +/// Fluent migration that creates the `ammo_alias` table, storing alternate/historical names for +/// an ammo record. Row data itself is populated/updated via a separate data-import process. +/// +/// Author: Richard J Hancock +/// Date: 2024/03/23 import Fluent import FluentSQL extension BattleTech { + /// Creates the `ammo_alias` table, which references `ammo` via a required `ammo_id` + /// foreign key. struct CreateAmmoAlias: AsyncMigration { + /// Creates the `ammo_alias` table. + /// - Parameter database: The database connection to apply the schema change to. + /// - Throws: An error if the schema change fails. func prepare(on database: any Database) async throws { try await database.schema(for: BattleTech.AmmoAlias.self) .id() @@ -34,6 +38,9 @@ extension BattleTech { .create() } + /// Drops the `ammo_alias` table. + /// - Parameter database: The database connection to apply the schema change to. + /// - Throws: An error if the schema change fails. func revert(on database: any Database) async throws { try await database.schema(for: BattleTech.AmmoAlias.self).delete() } @@ -41,6 +48,7 @@ extension BattleTech { } extension BattleTech.AmmoAlias { + /// Column-name constants (``FieldKey``s) for the `ammo_alias` table, version 2024-03-27. enum V20240327 { static let schemaName = "ammo_alias" static let spaceName = "battletech" diff --git a/Sources/App/Migrations/2024-03-27-CreateAmmoRulePivot.swift b/Sources/App/Migrations/BattleTech/2024-03-27-CreateAmmoRulePivot.swift similarity index 63% rename from Sources/App/Migrations/2024-03-27-CreateAmmoRulePivot.swift rename to Sources/App/Migrations/BattleTech/2024-03-27-CreateAmmoRulePivot.swift index 65b3803..ab07ed3 100644 --- a/Sources/App/Migrations/2024-03-27-CreateAmmoRulePivot.swift +++ b/Sources/App/Migrations/BattleTech/2024-03-27-CreateAmmoRulePivot.swift @@ -1,17 +1,21 @@ -// -// CreateRuleWeaponPivot.swift -// -// Create the pivot for Rules with Weapons -// -// Author: Richard J Hancock -// Date: 2024/03/16 -// +/// CreateAmmoRulePivot.swift +/// +/// Fluent migration that creates the `ammo_rule_pivot` table, the join table between ammo and +/// the rules that govern it. +/// +/// Author: Richard J Hancock +/// Date: 2024/03/16 import Fluent import FluentSQL extension BattleTech { + /// Creates the `ammo_rule_pivot` table — the many-to-many join table between + /// ``BattleTech/Rule`` and ``BattleTech/Ammo``. struct CreateAmmoRulePivot: AsyncMigration { + /// Creates the `ammo_rule_pivot` table. + /// - Parameter database: The database connection to apply the schema change to. + /// - Throws: An error if the schema change fails. func prepare(on database: any Database) async throws { try await database.schema(for: BattleTech.AmmoRulePivot.self) .id() @@ -33,6 +37,9 @@ extension BattleTech { .create() } + /// Drops the `ammo_rule_pivot` table. + /// - Parameter database: The database connection to apply the schema change to. + /// - Throws: An error if the schema change fails. func revert(on database: any Database) async throws { try await database.schema(for: BattleTech.AmmoRulePivot.self).delete() } @@ -40,6 +47,8 @@ extension BattleTech { } extension BattleTech.AmmoRulePivot { + /// Column-name constants (``FieldKey``s) for the `ammo_rule_pivot` table, version + /// 2024-03-27. enum V20240327 { static let schemaName = "ammo_rule_pivot" static let spaceName = "battletech" diff --git a/Sources/App/Migrations/2024-03-27-CreateMunitionType.swift b/Sources/App/Migrations/BattleTech/2024-03-27-CreateMunitionType.swift similarity index 52% rename from Sources/App/Migrations/2024-03-27-CreateMunitionType.swift rename to Sources/App/Migrations/BattleTech/2024-03-27-CreateMunitionType.swift index 1991921..82b766c 100644 --- a/Sources/App/Migrations/2024-03-27-CreateMunitionType.swift +++ b/Sources/App/Migrations/BattleTech/2024-03-27-CreateMunitionType.swift @@ -1,17 +1,21 @@ -// -// CreateTechBase.swift -// -// Creates the Tech Base model. Data Imported/Updated via data Import -// -// Author: Richard J Hancock -// Date: 2024/03/16 -// +/// CreateMunitionType.swift +/// +/// Fluent migration that creates the `munition_type` table, storing munition-type lookup +/// values referenced by ammo. Row data itself is populated/updated via a separate data-import +/// process. +/// +/// Author: Richard J Hancock +/// Date: 2024/03/16 import Fluent import FluentSQL extension BattleTech { + /// Creates the `munition_type` table. struct CreateMunitionType: AsyncMigration { + /// Creates the `munition_type` table. + /// - Parameter database: The database connection to apply the schema change to. + /// - Throws: An error if the schema change fails. func prepare(on database: any Database) async throws { try await database.schema(for: BattleTech.MunitionType.self) .id() @@ -20,6 +24,9 @@ extension BattleTech { .create() } + /// Drops the `munition_type` table. + /// - Parameter database: The database connection to apply the schema change to. + /// - Throws: An error if the schema change fails. func revert(on database: any Database) async throws { try await database.schema(for: BattleTech.MunitionType.self).delete() } @@ -27,6 +34,7 @@ extension BattleTech { } extension BattleTech.MunitionType { + /// Column-name constants (``FieldKey``s) for the `munition_type` table, version 2024-03-27. enum V20240327 { static let schemaName = "munition_type" static let spaceName = "battletech" diff --git a/Sources/App/Migrations/2024-03-30-CreateEquipment.swift b/Sources/App/Migrations/BattleTech/2024-03-30-CreateEquipment.swift similarity index 82% rename from Sources/App/Migrations/2024-03-30-CreateEquipment.swift rename to Sources/App/Migrations/BattleTech/2024-03-30-CreateEquipment.swift index 3914df9..c5b6c94 100644 --- a/Sources/App/Migrations/2024-03-30-CreateEquipment.swift +++ b/Sources/App/Migrations/BattleTech/2024-03-30-CreateEquipment.swift @@ -1,17 +1,21 @@ -// -// CreateEquipment.swift -// -// Creates the Equipment model for CSV Import -// -// Author: Richard J Hancock -// Date: 2024/03/30 -// +/// CreateEquipment.swift +/// +/// Fluent migration that creates the `equipment` table, storing BattleTech equipment data +/// (mirrors the `weapons`/`ammo` tables' shape, minus range/damage fields) for CSV import. +/// +/// Author: Richard J Hancock +/// Date: 2024/03/30 import Fluent import FluentSQL extension BattleTech { + /// Creates the `equipment` table. struct CreateEquipment: AsyncMigration { + /// Creates the `equipment` table with its columns, foreign keys to `tech_base`/ + /// `tech_level`, and uniqueness constraint. + /// - Parameter database: The database connection to apply the schema change to. + /// - Throws: An error if the schema change fails. func prepare(on database: any Database) async throws { try await database.schema(for: BattleTech.Equipment.self) .id() @@ -52,6 +56,9 @@ extension BattleTech { .create() } + /// Drops the `equipment` table. + /// - Parameter database: The database connection to apply the schema change to. + /// - Throws: An error if the schema change fails. func revert(on database: any Database) async throws { try await database.schema(for: BattleTech.Equipment.self).delete() } @@ -59,6 +66,7 @@ extension BattleTech { } extension BattleTech.Equipment { + /// Column-name constants (``FieldKey``s) for the `equipment` table, version 2024-03-30. enum V20240330 { static let schemaName = "equipment" static let spaceName = "battletech" diff --git a/Sources/App/Migrations/2024-03-30-CreateEquipmentAlias.swift b/Sources/App/Migrations/BattleTech/2024-03-30-CreateEquipmentAlias.swift similarity index 61% rename from Sources/App/Migrations/2024-03-30-CreateEquipmentAlias.swift rename to Sources/App/Migrations/BattleTech/2024-03-30-CreateEquipmentAlias.swift index 7d8d0d1..aa6d723 100644 --- a/Sources/App/Migrations/2024-03-30-CreateEquipmentAlias.swift +++ b/Sources/App/Migrations/BattleTech/2024-03-30-CreateEquipmentAlias.swift @@ -1,17 +1,22 @@ -// -// CreateEquipmentAlias.swift -// -// Creates the Equipment Alias model. Data Imported/Updated via data Import -// -// Author: Richard J Hancock -// Date: 2024/03/30 -// +/// CreateEquipmentAlias.swift +/// +/// Fluent migration that creates the `equipment_alias` table, storing alternate/historical +/// names for an equipment record. Row data itself is populated/updated via a separate +/// data-import process. +/// +/// Author: Richard J Hancock +/// Date: 2024/03/30 import Fluent import FluentSQL extension BattleTech { + /// Creates the `equipment_alias` table, which references `equipment` via a required + /// `equipment_id` foreign key. struct CreateEquipmentAlias: AsyncMigration { + /// Creates the `equipment_alias` table. + /// - Parameter database: The database connection to apply the schema change to. + /// - Throws: An error if the schema change fails. func prepare(on database: any Database) async throws { try await database.schema(for: BattleTech.EquipmentAlias.self) .id() @@ -34,6 +39,9 @@ extension BattleTech { .create() } + /// Drops the `equipment_alias` table. + /// - Parameter database: The database connection to apply the schema change to. + /// - Throws: An error if the schema change fails. func revert(on database: any Database) async throws { try await database.schema(for: BattleTech.EquipmentAlias.self).delete() } @@ -41,6 +49,8 @@ extension BattleTech { } extension BattleTech.EquipmentAlias { + /// Column-name constants (``FieldKey``s) for the `equipment_alias` table, version + /// 2024-03-30. enum V20240330 { static let schemaName = "equipment_alias" static let spaceName = "battletech" diff --git a/Sources/App/Migrations/2024-03-30-CreateEquipmentRulePivot.swift b/Sources/App/Migrations/BattleTech/2024-03-30-CreateEquipmentRulePivot.swift similarity index 63% rename from Sources/App/Migrations/2024-03-30-CreateEquipmentRulePivot.swift rename to Sources/App/Migrations/BattleTech/2024-03-30-CreateEquipmentRulePivot.swift index 9cd247d..fa599f7 100644 --- a/Sources/App/Migrations/2024-03-30-CreateEquipmentRulePivot.swift +++ b/Sources/App/Migrations/BattleTech/2024-03-30-CreateEquipmentRulePivot.swift @@ -1,17 +1,21 @@ -// -// CreateEquipmentRulePivot.swift -// -// Create the pivot for Rules with Weapons -// -// Author: Richard J Hancock -// Date: 2024/03/16 -// +/// CreateEquipmentRulePivot.swift +/// +/// Fluent migration that creates the `equipment_rule_pivot` table, the join table between +/// equipment and the rules that govern it. +/// +/// Author: Richard J Hancock +/// Date: 2024/03/16 import Fluent import FluentSQL extension BattleTech { + /// Creates the `equipment_rule_pivot` table — the many-to-many join table between + /// ``BattleTech/Rule`` and ``BattleTech/Equipment``. struct CreateEquipmentRulePivot: AsyncMigration { + /// Creates the `equipment_rule_pivot` table. + /// - Parameter database: The database connection to apply the schema change to. + /// - Throws: An error if the schema change fails. func prepare(on database: any Database) async throws { try await database.schema(for: BattleTech.EquipmentRulePivot.self) .id() @@ -33,6 +37,9 @@ extension BattleTech { .create() } + /// Drops the `equipment_rule_pivot` table. + /// - Parameter database: The database connection to apply the schema change to. + /// - Throws: An error if the schema change fails. func revert(on database: any Database) async throws { try await database.schema(for: BattleTech.EquipmentRulePivot.self).delete() } @@ -40,6 +47,8 @@ extension BattleTech { } extension BattleTech.EquipmentRulePivot { + /// Column-name constants (``FieldKey``s) for the `equipment_rule_pivot` table, version + /// 2024-03-30. enum V20240330 { static let schemaName = "equipment_rule_pivot" static let spaceName = "battletech" diff --git a/Sources/App/Migrations/BattleTech/2024-04-09-AddStartYearToEra.swift b/Sources/App/Migrations/BattleTech/2024-04-09-AddStartYearToEra.swift new file mode 100644 index 0000000..11ce645 --- /dev/null +++ b/Sources/App/Migrations/BattleTech/2024-04-09-AddStartYearToEra.swift @@ -0,0 +1,46 @@ +/// AddStartYearToEras.swift +/// +/// Fluent migration that adds a `start_year` column to the existing `eras` table — a schema +/// alteration rather than a new-table migration. +/// +/// Author: Richard J Hancock +/// Date: 2024/02/12 + +import Fluent +import FluentSQL + +extension BattleTech { + /// Adds the `start_year` column to `eras`. + /// + /// Unlike the `Create*` migrations above, this one alters an existing table rather than + /// creating a new one: ``prepare(on:)`` adds the field, ``revert(on:)`` removes it again. + struct AddStartYearToEras: AsyncMigration { + /// Adds the `start_year` field to the `eras` table. + /// - Parameter database: The database connection to apply the schema change to. + /// - Throws: An error if the schema change fails. + func prepare(on database: any Database) async throws { + try await database.schema(for: BattleTech.Era.self) + .field(BattleTech.Era.V20240409.startYear, .int) + .update() + } + + /// Removes the `start_year` field from the `eras` table. + /// - Parameter database: The database connection to apply the schema change to. + /// - Throws: An error if the schema change fails. + func revert(on database: any Database) async throws { + try await database.schema(for: BattleTech.Era.self) + .deleteField(BattleTech.Era.V20240409.startYear) + .update() + } + } +} + +extension BattleTech.Era { + /// Column-name constant (``FieldKey``) for the `start_year` column added to `eras` by this + /// migration. Only new/changed columns get their own version namespace here — unchanged + /// columns keep referencing the enum from the migration that introduced them (e.g. + /// ``V20240212``). + enum V20240409 { + static let startYear = FieldKey(stringLiteral: "start_year") + } +} diff --git a/Sources/App/Migrations/BattleTech/2024-04-15-AddImageToFactionName.swift b/Sources/App/Migrations/BattleTech/2024-04-15-AddImageToFactionName.swift new file mode 100644 index 0000000..51399aa --- /dev/null +++ b/Sources/App/Migrations/BattleTech/2024-04-15-AddImageToFactionName.swift @@ -0,0 +1,41 @@ +/// AddImageToFactionName.swift +/// +/// Fluent migration that adds an `image_name` column to the existing `faction_names` table — a +/// schema alteration rather than a new-table migration. +/// +/// Author: Richard J Hancock +/// Date: 2024/04/15 + +import Fluent +import FluentSQL + +extension BattleTech { + /// Adds the `image_name` column to `faction_names`. + struct AddImageToFactionName: AsyncMigration { + /// Adds the `image_name` field to the `faction_names` table. + /// - Parameter database: The database connection to apply the schema change to. + /// - Throws: An error if the schema change fails. + func prepare(on database: any Database) async throws { + try await database.schema(for: BattleTech.FactionName.self) + .field(BattleTech.FactionName.V20240415.image, .string) + .update() + } + + /// Removes the `image_name` field from the `faction_names` table. + /// - Parameter database: The database connection to apply the schema change to. + /// - Throws: An error if the schema change fails. + func revert(on database: any Database) async throws { + try await database.schema(for: BattleTech.FactionName.self) + .deleteField(BattleTech.FactionName.V20240415.image) + .update() + } + } +} + +extension BattleTech.FactionName { + /// Column-name constant (``FieldKey``) for the `image_name` column added to + /// `faction_names` by this migration. + enum V20240415 { + static let image = FieldKey(stringLiteral: "image_name") + } +} diff --git a/Sources/App/Migrations/BattleTech/2026-07-18-FixAmmoAeroUseColumnName.swift b/Sources/App/Migrations/BattleTech/2026-07-18-FixAmmoAeroUseColumnName.swift new file mode 100644 index 0000000..e229ac0 --- /dev/null +++ b/Sources/App/Migrations/BattleTech/2026-07-18-FixAmmoAeroUseColumnName.swift @@ -0,0 +1,54 @@ +/// FixAmmoAeroUseColumnName.swift +/// +/// Fluent migration that repairs a copy-paste bug in ``BattleTech/CreateAmmo``: the `aeroUse` +/// column was declared with the literal key `"FieldKey"` instead of a real column name, so any +/// database that already ran that migration has an `ammo` column literally named `FieldKey`. +/// This migration renames that column to `aero_use` to match the corrected +/// ``BattleTech/Ammo/V20240327/aeroUse`` key. On a fresh database — where `CreateAmmo` already +/// creates the column as `aero_use` — the rename is skipped as a no-op. +/// +/// Author: Richard J Hancock +/// Date: 2026/07/18 + +import Fluent +import FluentSQL + +extension BattleTech { + /// Renames the `ammo` table's mistakenly-named `FieldKey` column to `aero_use`, if present. + struct FixAmmoAeroUseColumnName: AsyncMigration { + /// Renames `ammo."FieldKey"` to `ammo.aero_use` only if the old, mistakenly-named column + /// still exists — a no-op on any database where `CreateAmmo` already used the corrected + /// column name. + /// - Parameter database: The database connection to apply the schema change to. + /// - Throws: An error if the underlying `DO` block fails to run. + func prepare(on database: any Database) async throws { + guard let sqlDatabase = database as? any SQLDatabase, + let space = BattleTech.Ammo.space + else { + return + } + + try await sqlDatabase.raw( + """ + DO $$ + BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = \(literal: space) + AND table_name = \(literal: BattleTech.Ammo.schema) + AND column_name = 'FieldKey' + ) THEN + ALTER TABLE \(ident: space).\(ident: BattleTech.Ammo.schema) + RENAME COLUMN \(ident: "FieldKey") TO \(ident: "aero_use"); + END IF; + END $$; + """ + ).run() + } + + /// Intentionally a no-op: reverting would mean deliberately restoring the original + /// `"FieldKey"` typo, which is never desirable. + /// - Parameter database: The database connection (unused). + func revert(on database: any Database) async throws {} + } +} diff --git a/Sources/App/Migrations/Database+Schema.swift b/Sources/App/Migrations/Database+Schema.swift index 09a25c0..5331f90 100644 --- a/Sources/App/Migrations/Database+Schema.swift +++ b/Sources/App/Migrations/Database+Schema.swift @@ -1,7 +1,21 @@ +/// Database+Schema.swift +/// +/// Shared helpers used by every migration in this project (see `APIMigrations.swift` for +/// the ordered list of migrations that use them). This file is not itself a migration — +/// it extends Fluent's `Database` and `DatabaseSchema.FieldConstraint` types so individual +/// migrations can create tables inside a named Postgres schema/namespace (e.g. `security`, +/// `megamek`, via a model's `static var space`) and declare foreign keys that correctly +/// reference a model living in a different schema/space. + import Fluent import FluentSQL extension Database { + /// Returns the schema builder for `Model`, first creating the model's Postgres + /// schema/namespace (e.g. `security`) if it declares one via `Model.space`. + /// - Parameter for: The Fluent model type whose table/schema builder is being resolved. + /// - Returns: A `SchemaBuilder` for the model's table, scoped to its schema/space if any. + /// - Throws: An error if the `CREATE SCHEMA IF NOT EXISTS` statement fails to run. public func schema(for: Model.Type) async throws -> SchemaBuilder { if let space = Model.space, let sqlDatabase = self as? any SQLDatabase { try await sqlDatabase.raw("CREATE SCHEMA IF NOT EXISTS \(ident: space)").run() @@ -11,6 +25,11 @@ extension Database { return self.schema(Model.schema) } + /// Synchronous variant of the same lookup. Note: the `CREATE SCHEMA IF NOT EXISTS` + /// statement is fired without being awaited, so prefer the `async throws` overload + /// above where the caller can wait for schema creation to complete. + /// - Parameter for: The Fluent model type whose table/schema builder is being resolved. + /// - Returns: A `SchemaBuilder` for the model's table, scoped to its schema/space if any. public func schema(for: Model.Type) -> SchemaBuilder { if let space = Model.space, let sqlDatabase = self as? any SQLDatabase { _ = sqlDatabase.raw("CREATE SCHEMA IF NOT EXISTS \(ident: space)").run() @@ -22,6 +41,16 @@ extension Database { } extension DatabaseSchema.FieldConstraint { + /// Builds a foreign-key field constraint referencing `field` on `modelType`, resolving + /// the target's schema/space (e.g. `security`) automatically so cross-schema foreign + /// keys (e.g. `megamek.servers` referencing `security.users`) work without callers + /// having to spell out the target schema by hand. + /// - Parameters: + /// - modelType: The Fluent model type being referenced. + /// - field: The `FieldKey` of the referenced column (typically the target's `id`). + /// - onDelete: Action to take when the referenced row is deleted. Defaults to `.noAction`. + /// - onUpdate: Action to take when the referenced row is updated. Defaults to `.noAction`. + /// - Returns: A field constraint usable with `.references(...)` in a migration. public static func references( _ modelType: (some Model).Type, _ field: FieldKey, diff --git a/Sources/App/Migrations/MegaMek/2025-05-06-CreateMMServer.swift b/Sources/App/Migrations/MegaMek/2025-05-06-CreateMMServer.swift new file mode 100644 index 0000000..c6843ff --- /dev/null +++ b/Sources/App/Migrations/MegaMek/2025-05-06-CreateMMServer.swift @@ -0,0 +1,71 @@ +/// 2025-05-06-CreateMMServer.swift +/// mul-api +/// +/// Fluent migration (dated 2025-05-06) that creates the `megamek.servers` table, used +/// to register MegaMek game servers (address, port, version, password state, MOTD, etc.) +/// for discovery. +// +// 2025-05-06-CreateMMServer.swift +// mul-api +// +// Created by Richard Hancock on 5/9/25. +// + +import Fluent +import FluentSQL + +extension MegaMek { + /// Creates the `megamek.servers` table. + struct CreateServer: AsyncMigration { + /// Creates the `servers` table with its initial columns. + /// - Parameter database: The database connection to apply the migration on. + /// - Throws: An error if the schema change fails to apply. + func prepare(on database: any Database) async throws { + try await database.schema(for: MegaMek.Server.self) + .id() + .field(MegaMek.Server.V20250509.port, .int, .required) + .field(MegaMek.Server.V20250509.ipAddress, .string, .required) + .field(MegaMek.Server.V20250509.passworded, .bool, .required) + .field(MegaMek.Server.V20250509.users, .string, .required) + .field(MegaMek.Server.V20250509.serverKey, .string, .required) + .field(MegaMek.Server.V20250509.version, .string, .required) + .field(MegaMek.Server.V20250509.phase, .string, .required) + .field(MegaMek.Server.V20250509.motd, .string) + .field(MegaMek.Server.V20250509.createdAt, .datetime) + .field(MegaMek.Server.V20250509.updatedAt, .datetime) + .unique(on: MegaMek.Server.V20250509.ipAddress, MegaMek.Server.V20250509.port) + .unique(on: MegaMek.Server.V20250509.serverKey) + .create() + } + + /// Drops the `servers` table. + /// - Parameter database: The database connection to revert the migration on. + /// - Throws: An error if the schema change fails to revert. + func revert(on database: any Database) async throws { + try await database.schema(for: MegaMek.Server.self).delete() + } + } +} + +extension MegaMek.Server { + /// Stable, versioned column-name namespace for ``MegaMek/Server`` as of this migration + /// (2025-05-06, tagged `V20250509`). Keeps the database column names fixed even if the + /// Swift properties on the model are later renamed. + enum V20250509 { + static let schemaName = "servers" + static let spaceName = "megamek" + + static let id = FieldKey(stringLiteral: "id") + static let port = FieldKey(stringLiteral: "port") + static let ipAddress = FieldKey(stringLiteral: "ip_address") + static let passworded = FieldKey(stringLiteral: "passworded") + static let users = FieldKey(stringLiteral: "users") + static let serverKey = FieldKey(stringLiteral: "server_key") + static let version = FieldKey(stringLiteral: "version") + static let phase = FieldKey(stringLiteral: "phase") + static let motd = FieldKey(stringLiteral: "motd") + + static let createdAt = FieldKey(stringLiteral: "created_at") + static let updatedAt = FieldKey(stringLiteral: "updated_at") + } +} diff --git a/Sources/App/Migrations/Security/2022-12-27-CreateUser.swift b/Sources/App/Migrations/Security/2022-12-27-CreateUser.swift index ce08025..006d26f 100644 --- a/Sources/App/Migrations/Security/2022-12-27-CreateUser.swift +++ b/Sources/App/Migrations/Security/2022-12-27-CreateUser.swift @@ -1,5 +1,15 @@ -// -// CreateUser.swift +/// CreateUser.swift +/// +/// Fluent migration (dated 2022-12-27) that creates the `security.users` table, the +/// core user-account table backing authentication (username, password hash, email, +/// role, timestamps). +/// +/// Fluent migrations are versioned, ordered database-schema changes. Each type conforms +/// to ``AsyncMigration``, implementing `prepare(on:)` to apply the change and `revert(on:)` +/// to undo it. Migration files are named with a date prefix (here, 2022-12-27) because +/// migrations must run in a strict, one-directional order matching how the schema +/// actually evolved in production — running them out of order could apply a later change +/// before the schema it depends on exists. // // // Created by Richard Hancock on 12/27/22. @@ -9,7 +19,11 @@ import Fluent import FluentSQL extension Security { + /// Creates the `security.users` table. struct CreateUser: AsyncMigration { + /// Creates the `users` table with its initial columns. + /// - Parameter database: The database connection to apply the migration on. + /// - Throws: An error if the schema change fails to apply. func prepare(on database: any Database) async throws { try await database.schema(for: Security.User.self) .id() @@ -26,6 +40,9 @@ extension Security { .create() } + /// Drops the `users` table. + /// - Parameter database: The database connection to revert the migration on. + /// - Throws: An error if the schema change fails to revert. func revert(on database: any Database) async throws { try await database.schema(for: Security.User.self).delete() } @@ -33,6 +50,15 @@ extension Security { } extension Security.User { + /// Stable, versioned column-name namespace for ``Security/User`` as of this migration + /// (2022-12-27). + /// + /// A ``FieldKey`` namespace enum like this pins the actual database column names (e.g. + /// `"username"`) independently of the Swift property names used on the model. If a + /// Swift property is later renamed, the column name recorded here doesn't change, so + /// the real database schema and any existing data aren't silently broken. Later + /// migrations that alter this table add their own `V` enum rather than editing + /// this one, since this one reflects only what existed at this point in schema history. enum V20221227 { static let schemaName = "users" static let spaceName = "security" diff --git a/Sources/App/Migrations/Security/2023-01-19-CreateToken.swift b/Sources/App/Migrations/Security/2023-01-19-CreateToken.swift index 30400b2..325ded5 100644 --- a/Sources/App/Migrations/Security/2023-01-19-CreateToken.swift +++ b/Sources/App/Migrations/Security/2023-01-19-CreateToken.swift @@ -1,5 +1,7 @@ -// -// CreateToken.swift +/// CreateToken.swift +/// +/// Fluent migration (dated 2023-01-19) that creates the `security.tokens` table, +/// storing auth/session tokens tied to a ``Security/User`` via a foreign key. // // // Created by Richard Hancock on 1/19/23. @@ -9,7 +11,11 @@ import Fluent import FluentSQL extension Security { + /// Creates the `security.tokens` table. struct CreateToken: AsyncMigration { + /// Creates the `tokens` table with its initial columns. + /// - Parameter database: The database connection to apply the migration on. + /// - Throws: An error if the schema change fails to apply. func prepare(on database: any Database) async throws { try await database.schema(for: Security.Token.self) .id() @@ -27,6 +33,9 @@ extension Security { .create() } + /// Drops the `tokens` table. + /// - Parameter database: The database connection to revert the migration on. + /// - Throws: An error if the schema change fails to revert. func revert(on database: any Database) async throws { try await database.schema(for: Security.Token.self).delete() } @@ -34,6 +43,9 @@ extension Security { } extension Security.Token { + /// Stable, versioned column-name namespace for ``Security/Token`` as of this migration + /// (2023-01-19). Keeps the database column names fixed even if the Swift properties + /// on the model are later renamed. enum V20230119 { static let schemaName = "tokens" static let spaceName = "security" diff --git a/Sources/App/Migrations/Security/2023-03-12-CreateForgotPasswordToken.swift b/Sources/App/Migrations/Security/2023-03-12-CreateForgotPasswordToken.swift index 739b47d..6ad1ff2 100644 --- a/Sources/App/Migrations/Security/2023-03-12-CreateForgotPasswordToken.swift +++ b/Sources/App/Migrations/Security/2023-03-12-CreateForgotPasswordToken.swift @@ -1,5 +1,7 @@ -// -// CreateForgotPasswordToken.swift +/// CreateForgotPasswordToken.swift +/// +/// Fluent migration (dated 2023-03-12) that creates the `security.forgot_password_tokens` +/// table, storing password-reset tokens tied to a ``Security/User`` via a foreign key. // // Tokens for Forgot Password. // @@ -10,7 +12,11 @@ import Fluent import FluentSQL extension Security { + /// Creates the `security.forgot_password_tokens` table. struct CreateForgotPasswordToken: AsyncMigration { + /// Creates the `forgot_password_tokens` table with its initial columns. + /// - Parameter database: The database connection to apply the migration on. + /// - Throws: An error if the schema change fails to apply. func prepare(on database: any Database) async throws { try await database.schema(for: Security.ForgotPasswordToken.self) .id() @@ -28,6 +34,9 @@ extension Security { .create() } + /// Drops the `forgot_password_tokens` table. + /// - Parameter database: The database connection to revert the migration on. + /// - Throws: An error if the schema change fails to revert. func revert(on database: any Database) async throws { try await database.schema(for: Security.ForgotPasswordToken.self).delete() } @@ -35,6 +44,9 @@ extension Security { } extension Security.ForgotPasswordToken { + /// Stable, versioned column-name namespace for ``Security/ForgotPasswordToken`` as of + /// this migration (2023-03-12). Keeps the database column names fixed even if the + /// Swift properties on the model are later renamed. enum V20230312 { static let schemaName = "forgot_password_tokens" static let spaceName = "security" diff --git a/Sources/App/Migrations/Security/2023-03-16-AddConfirmedAtToUser.swift b/Sources/App/Migrations/Security/2023-03-16-AddConfirmedAtToUser.swift index 721f9de..55be6e0 100644 --- a/Sources/App/Migrations/Security/2023-03-16-AddConfirmedAtToUser.swift +++ b/Sources/App/Migrations/Security/2023-03-16-AddConfirmedAtToUser.swift @@ -1,5 +1,7 @@ -// -// AddConfirmedAtToUser.swift +/// AddConfirmedAtToUser.swift +/// +/// Fluent migration (dated 2023-03-16) that adds the `confirmed_at` column to the +/// `security.users` table, recording when a user confirmed their email/account. // // Adds the ConfirmedAt field to User. // @@ -11,13 +13,20 @@ import FluentSQL extension Security { + /// Adds the `confirmed_at` column to the `users` table. struct AddConfirmedAtToUser: AsyncMigration { + /// Adds the `confirmed_at` column to `users`. + /// - Parameter database: The database connection to apply the migration on. + /// - Throws: An error if the schema change fails to apply. func prepare(on database: any Database) async throws { try await database.schema(for: Security.User.self) .field(Security.User.V20230316.confirmedAt, .datetime) .update() } + /// Removes the `confirmed_at` column from `users`. + /// - Parameter database: The database connection to revert the migration on. + /// - Throws: An error if the schema change fails to revert. func revert(on database: any Database) async throws { try await database.schema(for: Security.User.self) .deleteField(Security.User.V20230316.confirmedAt) @@ -27,6 +36,9 @@ extension Security { } extension Security.User { + /// Versioned column-name namespace adding `confirmed_at` to ``Security/User`` as of + /// this migration (2023-03-16). Keeps the column name fixed even if the corresponding + /// Swift property is later renamed. enum V20230316 { static let confirmedAt = FieldKey(stringLiteral: "confirmed_at") } diff --git a/Sources/App/Migrations/Security/2023-03-16-CreteConfimrationToken.swift b/Sources/App/Migrations/Security/2023-03-16-CreteConfimrationToken.swift index a28ce5f..1b9b04b 100644 --- a/Sources/App/Migrations/Security/2023-03-16-CreteConfimrationToken.swift +++ b/Sources/App/Migrations/Security/2023-03-16-CreteConfimrationToken.swift @@ -1,5 +1,8 @@ -// -// CreateConfirmationToken.swift +/// CreateConfirmationToken.swift +/// +/// Fluent migration (dated 2023-03-16) that creates the `security.confirmation_tokens` +/// table, storing email/account-confirmation tokens tied to a ``Security/User`` via a +/// foreign key. // // Tokens for Confirmation. // @@ -10,7 +13,11 @@ import Fluent import FluentSQL extension Security { + /// Creates the `security.confirmation_tokens` table. struct CreateConfirmationToken: AsyncMigration { + /// Creates the `confirmation_tokens` table with its initial columns. + /// - Parameter database: The database connection to apply the migration on. + /// - Throws: An error if the schema change fails to apply. func prepare(on database: any Database) async throws { try await database.schema(for: Security.ConfirmationToken.self) .id() @@ -28,6 +35,9 @@ extension Security { .create() } + /// Drops the `confirmation_tokens` table. + /// - Parameter database: The database connection to revert the migration on. + /// - Throws: An error if the schema change fails to revert. func revert(on database: any Database) async throws { try await database.schema(for: Security.ConfirmationToken.self).delete() } @@ -35,6 +45,9 @@ extension Security { } extension Security.ConfirmationToken { + /// Stable, versioned column-name namespace for ``Security/ConfirmationToken`` as of + /// this migration (2023-03-16). Keeps the database column names fixed even if the + /// Swift properties on the model are later renamed. enum V20230316 { static let schemaName = "confirmation_tokens" static let spaceName = "security" diff --git a/Sources/App/Migrations/Security/2023-08-18-AddFirstAndLastNameToUser.swift b/Sources/App/Migrations/Security/2023-08-18-AddFirstAndLastNameToUser.swift index 168ac8d..6757511 100644 --- a/Sources/App/Migrations/Security/2023-08-18-AddFirstAndLastNameToUser.swift +++ b/Sources/App/Migrations/Security/2023-08-18-AddFirstAndLastNameToUser.swift @@ -1,3 +1,7 @@ +/// 2023-08-18-AddFirstAndLastNameToUser.swift +/// +/// Fluent migration (dated 2023-08-18) that adds the `first_name` and `last_name` +/// columns to the `security.users` table. // // 2023-08-18-AddFirstAndLastNameToUser.swift // @@ -10,7 +14,11 @@ import Fluent import FluentSQL extension Security { + /// Adds the `first_name` and `last_name` columns to the `users` table. struct AddFirstAndLastNameToUser: AsyncMigration { + /// Adds the `first_name` and `last_name` columns to `users`. + /// - Parameter database: The database connection to apply the migration on. + /// - Throws: An error if the schema change fails to apply. func prepare(on database: any Database) async throws { try await database.schema(for: Security.User.self) .field(Security.User.V20230818.firstName, .string) @@ -18,6 +26,9 @@ extension Security { .update() } + /// Removes the `first_name` and `last_name` columns from `users`. + /// - Parameter database: The database connection to revert the migration on. + /// - Throws: An error if the schema change fails to revert. func revert(on database: any Database) async throws { try await database.schema(for: Security.User.self) .deleteField(Security.User.V20230818.firstName) @@ -28,6 +39,8 @@ extension Security { } extension Security.User { + /// Versioned column-name namespace adding `first_name`/`last_name` to ``Security/User`` + /// as of this migration (2023-08-18). enum V20230818 { static let firstName = FieldKey(stringLiteral: "first_name") static let lastName = FieldKey(stringLiteral: "last_name") diff --git a/Sources/App/Migrations/Security/2023-08-18-AddUnconfirmedEmailToUser.swift b/Sources/App/Migrations/Security/2023-08-18-AddUnconfirmedEmailToUser.swift index 83bb12e..c687cfe 100644 --- a/Sources/App/Migrations/Security/2023-08-18-AddUnconfirmedEmailToUser.swift +++ b/Sources/App/Migrations/Security/2023-08-18-AddUnconfirmedEmailToUser.swift @@ -1,3 +1,7 @@ +/// 2023-08-18-AddUnconfirmedEmailToUser.swift +/// +/// Fluent migration (dated 2023-08-18) that adds the `unconfirmed_email` column to the +/// `security.users` table, holding a new email address pending re-confirmation. // // 2023-08-18-AddUnconfirmedEmailToUser.swift // @@ -10,13 +14,20 @@ import Fluent import FluentSQL extension Security { + /// Adds the `unconfirmed_email` column to the `users` table. struct AddUnconfirmedEmailToUser: AsyncMigration { + /// Adds the `unconfirmed_email` column to `users`. + /// - Parameter database: The database connection to apply the migration on. + /// - Throws: An error if the schema change fails to apply. func prepare(on database: any Database) async throws { try await database.schema(for: Security.User.self) .field(Security.User.V20230818A.unconfirmedEmail, .string) .update() } + /// Removes the `unconfirmed_email` column from `users`. + /// - Parameter database: The database connection to revert the migration on. + /// - Throws: An error if the schema change fails to revert. func revert(on database: any Database) async throws { try await database.schema(for: Security.User.self) .deleteField(Security.User.V20230818A.unconfirmedEmail) @@ -26,6 +37,8 @@ extension Security { } extension Security.User { + /// Versioned column-name namespace adding `unconfirmed_email` to ``Security/User`` as + /// of this migration (2023-08-18). enum V20230818A { static let unconfirmedEmail = FieldKey(stringLiteral: "unconfirmed_email") } diff --git a/Sources/App/Models/BattleTech.swift b/Sources/App/Models/BattleTech.swift index b975185..82b1423 100644 --- a/Sources/App/Models/BattleTech.swift +++ b/Sources/App/Models/BattleTech.swift @@ -1,2 +1,10 @@ +/// Namespace for the entire BattleTech domain. +/// +/// Every Fluent model (``BattleTech/Era``, ``BattleTech/Faction``, `Weapon`, +/// `Ammo`, etc.), pivot/join table, and their migrations are declared as +/// `extension BattleTech { ... }` in sibling files. `BattleTech` itself holds no +/// data or behavior — it exists purely so related types can be written as +/// ``BattleTech/Era``, `BattleTech.Weapon`, and so on, grouped under one +/// dot-prefixed name instead of living loose at the top level. // Empty struct for the BattleTech parent Module struct BattleTech {} diff --git a/Sources/App/Models/BattleTech/Ammo+Extension.swift b/Sources/App/Models/BattleTech/Ammo+Extension.swift index 7e87c3b..8a7dec1 100644 --- a/Sources/App/Models/BattleTech/Ammo+Extension.swift +++ b/Sources/App/Models/BattleTech/Ammo+Extension.swift @@ -1,7 +1,17 @@ +/// Helper methods used by the CSV data importer to create/update ``BattleTech/Ammo`` records +/// and their related tech-base, tech-level, munition-type, rule, and alias associations. import Fluent import Vapor extension BattleTech.Ammo { + /// Finds an existing ammo record matching the CSV row, or creates a new one, then + /// overwrites it with the row's data and persists it. + /// + /// - Parameters: + /// - csvRow: The parsed ammo CSV row to import. + /// - database: The database connection to query and save through. + /// - Returns: The found-or-created, now up-to-date ``BattleTech/Ammo`` record. + /// - Throws: Rethrows any Fluent query/save errors encountered while matching or updating. static func findOrCreate(csvRow: Importers.AmmoCSVRow, on database: Database) async throws -> BattleTech.Ammo { var ammo: BattleTech.Ammo? = BattleTech.Ammo() @@ -20,6 +30,17 @@ extension BattleTech.Ammo { return ammo! } + /// Looks up an ammo record whose name matches exactly, or whose name matches one of the + /// given aliases, while also matching on tech rating, tonnage, tech level, and tech base + /// (resolving/creating those lookup records as needed). + /// + /// - Parameters: + /// - name: The ammo name to match exactly. + /// - aliases: Alternate names to match against if an exact name match isn't found. + /// - csvRow: The CSV row supplying the tech rating, tonnage, tech level, and tech base to match on. + /// - database: The database connection to query through. + /// - Returns: The matching ``BattleTech/Ammo`` record, or `nil` if none matches. + /// - Throws: Rethrows any Fluent query errors encountered while resolving lookups or matching. static func findByNameOrAliases( name: String, aliases: [String], @@ -60,6 +81,12 @@ extension BattleTech.Ammo { return nil } + /// Resolves (or creates) the named tech base and sets this ammo's foreign key to it. + /// + /// - Parameters: + /// - techBase: Name of the tech base (e.g. Inner Sphere, Clan) to attach. + /// - database: The database connection to query and save through. + /// - Throws: Rethrows any Fluent query/save errors from ``BattleTech/TechBase/findOrCreate(tentativeTechBase:with:)``. func attachTechBase(techBase: String, on database: Database) async throws { let record = try await BattleTech.TechBase.findOrCreate( tentativeTechBase: techBase, @@ -69,6 +96,12 @@ extension BattleTech.Ammo { self.$techBase.id = record.id! } + /// Resolves (or creates) the named tech level and sets this ammo's foreign key to it. + /// + /// - Parameters: + /// - techLevel: Name of the tech level to attach. + /// - database: The database connection to query and save through. + /// - Throws: Rethrows any Fluent query/save errors from ``BattleTech/TechLevel/findOrCreate(tentativeTechLevel:with:)``. func attachTechLevel(techLevel: String, on database: Database) async throws { let record = try await BattleTech.TechLevel.findOrCreate( tentativeTechLevel: techLevel, @@ -78,6 +111,12 @@ extension BattleTech.Ammo { self.$techLevelStatic.id = record.id! } + /// Resolves (or creates) the named munition type and sets this ammo's foreign key to it. + /// + /// - Parameters: + /// - munitionType: Name of the munition type (e.g. Standard, Inferno, Cluster) to attach. + /// - database: The database connection to query and save through. + /// - Throws: Rethrows any Fluent query/save errors from ``BattleTech/MunitionType/findOrCreate(tentativeMunitionType:with:)``. func attachMunitionType(munitionType: String, on database: Database) async throws { let record = try await BattleTech.MunitionType.findOrCreate( tentativeMunitionType: munitionType, @@ -87,6 +126,13 @@ extension BattleTech.Ammo { self.$munitionType.id = record.id! } + /// Resolves (or creates) each named rule and attaches it to this ammo via the + /// many-to-many rules relationship, skipping any that are already attached. + /// + /// - Parameters: + /// - rules: Names of the rules to attach. + /// - database: The database connection to query and save through. + /// - Throws: Rethrows any Fluent query errors from ``BattleTech/Rule/findOrCreate(tentativeRules:with:)``. func attachRules(rules: [String], on database: Database) async throws { let records = try await BattleTech.Rule.findOrCreate( tentativeRules: rules, @@ -103,6 +149,13 @@ extension BattleTech.Ammo { } } + /// Resolves (or creates) each named alias and links it to this ammo record so the + /// alternate name can be used to find this ammo again in future imports. + /// + /// - Parameters: + /// - aliases: Alternate names to attach to this ammo. + /// - database: The database connection to query and save through. + /// - Throws: Rethrows any Fluent query errors from ``BattleTech/AmmoAlias/findOrCreate(tentativeAliases:with:)``. func attachAliases(aliases: [String], on database: Database) async throws { let records = try await BattleTech.AmmoAlias.findOrCreate( tentativeAliases: aliases, @@ -121,6 +174,13 @@ extension BattleTech.Ammo { } + /// Overwrites this ammo's scalar fields from the CSV row, saves it, then attaches its + /// tech base, tech level, munition type, rules, and aliases. + /// + /// - Parameters: + /// - csvRow: The parsed ammo CSV row supplying the new field values. + /// - database: The database connection to save and attach related records through. + /// - Throws: Rethrows any Fluent save error from persisting this record. func updateFromCSVRow(csvRow: Importers.AmmoCSVRow, on database: Database) async throws { self.name = csvRow.name() self.techRating = csvRow.techRating() diff --git a/Sources/App/Models/BattleTech/Ammo.swift b/Sources/App/Models/BattleTech/Ammo.swift index 2986c5e..a4e89a5 100644 --- a/Sources/App/Models/BattleTech/Ammo.swift +++ b/Sources/App/Models/BattleTech/Ammo.swift @@ -1,7 +1,17 @@ +/// Defines the ``BattleTech/Ammo`` Fluent model, which represents a single row in the `ammo` +/// database table: one ammunition type (e.g. "Standard LRM Ammo") along with its tech-base, +/// tonnage/cost/critical-slot stats, and ballistic properties (damage per shot, rack size, etc). +/// This is the source-of-truth record served by the public read-only BattleTech data API. import Fluent import Vapor extension BattleTech { + /// Fluent model backing the `ammo` table. + /// + /// Each row is one ammunition entry sourced from MegaMek's data files, linked to a + /// ``BattleTech/TechBase``, a static ``BattleTech/TechLevel``, a ``BattleTech/MunitionType`` + /// (e.g. Standard, Inferno, Cluster), and any applicable ``BattleTech/Rule``s and + /// ``BattleTech/AmmoAlias`` name aliases. final class Ammo: Model, Content, @unchecked Sendable { static let schema = BattleTech.Ammo.V20240327.schemaName public static let space: String? = BattleTech.Ammo.V20240327.spaceName @@ -12,18 +22,26 @@ extension BattleTech { @Field(key: BattleTech.Ammo.V20240327.name) var name: String + // `@Parent` declares a required foreign-key relationship: this ammo belongs to exactly + // one tech base record (Inner Sphere, Clan, etc). @Parent(key: BattleTech.Ammo.V20240327.techBase) var techBase: BattleTech.TechBase + // `@Siblings` exposes a many-to-many relationship through the `AmmoRulePivot` join + // table: the optional/errata rulebook rules that apply to this ammo. @Siblings(through: BattleTech.AmmoRulePivot.self, from: \.$ammo, to: \.$rule) var rules: [BattleTech.Rule] + // Tech rating (A-F scale) describing how advanced/rare the technology is. @Field(key: BattleTech.Ammo.V20240327.techRating) var techRating: String @Parent(key: BattleTech.Ammo.V20240327.techLevelStatic) var techLevelStatic: BattleTech.TechLevel + // In-universe timeline dates (as free-form strings) marking this ammo's lifecycle: + // when it was first designed, entered production, became common, went extinct, etc. + // `@OptionalField` means the column may be null since not every entry has every date. @OptionalField(key: BattleTech.Ammo.V20240327.introductionDate) var introductionDate: String? @@ -42,6 +60,8 @@ extension BattleTech { @OptionalField(key: BattleTech.Ammo.V20240327.reIntroductionDate) var reIntroductionDate: String? + // Core stat block: weight in tons, equipment slots consumed, C-bill cost, and + // Battle Value (the point-cost system used to balance forces). @Field(key: BattleTech.Ammo.V20240327.tonnage) var tonnage: Double @@ -57,12 +77,15 @@ extension BattleTech { @OptionalField(key: BattleTech.Ammo.V20240327.rulesReference) var rulesReference: String? + // Whether this ammo counts as flak (anti-aircraft) fire for hit resolution purposes. @Field(key: BattleTech.Ammo.V20240327.countAsFlak) var countAsFlak: Bool @Parent(key: BattleTech.Ammo.V20240327.munitionType) var munitionType: BattleTech.MunitionType + // Ballistic profile: damage dealt by a single shot, the weapon rack size it's sized + // for, how many shots one ton/unit of this ammo provides, and the ammo-per-ton ratio. @Field(key: BattleTech.Ammo.V20240327.damagePerShot) var damagePerShot: Int @@ -75,6 +98,8 @@ extension BattleTech { @Field(key: BattleTech.Ammo.V20240327.ammoRatio) var ammoRatio: Double + // Capital-scale (used by large spacecraft weapons) and per-shot weight in kilograms, + // plus whether this ammo is usable by aerospace units. @Field(key: BattleTech.Ammo.V20240327.isCapital) var isCapital: Bool @@ -84,22 +109,58 @@ extension BattleTech { @Field(key: BattleTech.Ammo.V20240327.aeroUse) var aeroUse: Bool + // `@Children` is the inverse of `@Parent`: all the alternate names this ammo is known + // by, used to match legacy/varied naming when importing CSV data. @Children(for: \.$ammo) var aliases: [BattleTech.AmmoAlias] @Field(key: BattleTech.Ammo.V20240327.publishedAt) var publishedAt: Date? + // `@Timestamp` fields are managed automatically by Fluent: set on insert / on update. @Timestamp(key: BattleTech.Ammo.V20240327.createdAt, on: .create) var createdAt: Date? @Timestamp(key: BattleTech.Ammo.V20240327.updatedAt, on: .update) var updatedAt: Date? + /// Creates an empty instance for Fluent to populate when reading from the database. + /// + /// Defaults `publishedAt` to 60 days ago so newly-created records are already public. init() { self.publishedAt = Calendar.current.date(byAdding: .day, value: -60, to: Date()) } + /// Full member-wise initializer used when constructing an ammo record directly + /// (e.g. in tests or data importers) with all of its stats and related records. + /// + /// - Parameters: + /// - id: Optional primary key; leave `nil` for a new, unsaved record. + /// - name: Display name of the ammunition. + /// - techBase: The ``BattleTech/TechBase`` (e.g. Inner Sphere, Clan) this ammo belongs to. + /// - techRating: Tech rating (A-F scale) for how advanced/rare the tech is. + /// - techLevelStatic: The ``BattleTech/TechLevel`` this ammo is classified under. + /// - introductionDate: In-universe year this ammo was first introduced. + /// - prototypeDate: In-universe year this ammo existed as a prototype. + /// - productionDate: In-universe year mass production began. + /// - commonDate: In-universe year this ammo became commonly available. + /// - extinctionDate: In-universe year this ammo went extinct, if applicable. + /// - reIntroductionDate: In-universe year this ammo was reintroduced, if applicable. + /// - tonnage: Weight in tons. + /// - criticalSlots: Number of critical/equipment slots consumed. + /// - cost: Cost in C-bills. + /// - battleValue: Battle Value point cost used for force balancing. + /// - rulesReference: Rulebook page/section this ammo is defined in. + /// - countAsFlak: Whether this ammo counts as flak (anti-aircraft) fire. + /// - munitionType: The ``BattleTech/MunitionType`` (e.g. Standard, Inferno, Cluster). + /// - damagePerShot: Damage dealt by a single shot. + /// - rackSize: Weapon rack size this ammo is sized for. + /// - shots: Number of shots provided per ton/unit of this ammo. + /// - ammoRatio: Ammo-per-ton ratio. + /// - isCapital: Whether this is capital-scale ammo used by large spacecraft weapons. + /// - kilogramPerShot: Per-shot weight in kilograms. + /// - aeroUse: Whether this ammo is usable by aerospace units. + /// - publishedAt: When this record becomes visible via the public API; defaults to 60 days ago. init( id: UUID? = nil, name: String, diff --git a/Sources/App/Models/BattleTech/AmmoAlias+Extensions.swift b/Sources/App/Models/BattleTech/AmmoAlias+Extensions.swift index fee991d..5e3fae7 100644 --- a/Sources/App/Models/BattleTech/AmmoAlias+Extensions.swift +++ b/Sources/App/Models/BattleTech/AmmoAlias+Extensions.swift @@ -1,7 +1,17 @@ +/// Helper used by the CSV importer to resolve a batch of alias name strings into +/// ``BattleTech/AmmoAlias`` records, reusing existing ones where possible. import Fluent import Vapor extension BattleTech.AmmoAlias { + /// For each given name, returns the existing alias record with that name, or builds a + /// new (unsaved) one if none exists yet. + /// + /// - Parameters: + /// - tentativeAliases: The alias name strings to resolve. + /// - database: The database connection to query through. + /// - Returns: One ``BattleTech/AmmoAlias`` per input name, in the same order. + /// - Throws: Rethrows any Fluent query errors encountered while looking up existing aliases. static func findOrCreate( tentativeAliases: [String], with database: Database diff --git a/Sources/App/Models/BattleTech/AmmoAlias.swift b/Sources/App/Models/BattleTech/AmmoAlias.swift index 2cad5d5..e845a7a 100644 --- a/Sources/App/Models/BattleTech/AmmoAlias.swift +++ b/Sources/App/Models/BattleTech/AmmoAlias.swift @@ -1,7 +1,12 @@ +/// Defines the ``BattleTech/AmmoAlias`` Fluent model: an alternate name for an ammo record, +/// used so CSV imports can match ammo entries that MegaMek's data files refer to under +/// different names across revisions. import Fluent import Vapor extension BattleTech { + /// Fluent model backing the `ammo_aliases` table. Each row is one alternate name that + /// points back to a single ``BattleTech/Ammo`` record. final class AmmoAlias: Model, Content, @unchecked Sendable { static let schema = BattleTech.AmmoAlias.V20240327.schemaName public static let space: String? = BattleTech.AmmoAlias.V20240327.spaceName @@ -12,11 +17,19 @@ extension BattleTech { @Field(key: BattleTech.AmmoAlias.V20240327.name) var name: String + // `@Parent` declares the required foreign key back to the ammo record this alias + // refers to. @Parent(key: BattleTech.AmmoAlias.V20240327.ammo) var ammo: BattleTech.Ammo + /// Creates an empty instance for Fluent to populate when reading from the database. init() {} + /// Creates a new alias with the given name, not yet linked to an ammo record. + /// + /// - Parameters: + /// - id: Optional primary key; leave `nil` for a new, unsaved record. + /// - name: The alternate name to record. init(id: UUID? = nil, name: String) { self.id = id self.name = name diff --git a/Sources/App/Models/BattleTech/AmmoRulePivot.swift b/Sources/App/Models/BattleTech/AmmoRulePivot.swift index 68128aa..ecc6494 100644 --- a/Sources/App/Models/BattleTech/AmmoRulePivot.swift +++ b/Sources/App/Models/BattleTech/AmmoRulePivot.swift @@ -1,7 +1,12 @@ +/// Defines the ``BattleTech/AmmoRulePivot`` Fluent model: the join table implementing the +/// many-to-many relationship between ammo entries and the rulebook rules that apply to them. import Fluent import Vapor extension BattleTech { + /// Fluent pivot model backing the `ammo_rule_pivot` table. Each row links one + /// ``BattleTech/Ammo`` record to one ``BattleTech/Rule`` record; used by `Ammo.rules` + /// (declared via `@Siblings`) to look up which rules apply to a given ammo type. final class AmmoRulePivot: Model, Content, @unchecked Sendable { static let schema = BattleTech.AmmoRulePivot.V20240327.schemaName public static let space: String? = BattleTech.AmmoRulePivot.V20240327.spaceName @@ -15,6 +20,7 @@ extension BattleTech { @Parent(key: BattleTech.AmmoRulePivot.V20240327.ammo) var ammo: BattleTech.Ammo + /// Creates an empty instance for Fluent to populate when reading from the database. init() {} } } diff --git a/Sources/App/Models/BattleTech/Equipment+Extension.swift b/Sources/App/Models/BattleTech/Equipment+Extension.swift index 4425ca1..d2e00a0 100644 --- a/Sources/App/Models/BattleTech/Equipment+Extension.swift +++ b/Sources/App/Models/BattleTech/Equipment+Extension.swift @@ -1,7 +1,17 @@ +/// Helper methods used by the CSV data importer to create/update ``BattleTech/Equipment`` +/// records and their related tech-base, tech-level, rule, and alias associations. import Fluent import Vapor extension BattleTech.Equipment { + /// Finds an existing equipment record matching the CSV row, or creates a new one, then + /// overwrites it with the row's data and persists it. + /// + /// - Parameters: + /// - csvRow: The parsed equipment CSV row to import. + /// - database: The database connection to query and save through. + /// - Returns: The found-or-created, now up-to-date ``BattleTech/Equipment`` record. + /// - Throws: Rethrows any Fluent query/save errors encountered while matching or updating. static func findOrCreate( csvRow: Importers.EquipmentCSVRow, on database: Database @@ -27,6 +37,17 @@ extension BattleTech.Equipment { return equipment! } + /// Looks up an equipment record whose name matches exactly, or whose name matches one of + /// the given aliases, while also matching on tech rating, tonnage, tech level, and tech + /// base (resolving/creating those lookup records as needed). + /// + /// - Parameters: + /// - name: The equipment name to match exactly. + /// - aliases: Alternate names to match against if an exact name match isn't found. + /// - csvRow: The CSV row supplying the tech rating, tonnage, tech level, and tech base to match on. + /// - database: The database connection to query through. + /// - Returns: The matching ``BattleTech/Equipment`` record, or `nil` if none matches. + /// - Throws: Rethrows any Fluent query errors encountered while resolving lookups or matching. static func findByNameOrAliases( name: String, aliases: [String], @@ -67,6 +88,12 @@ extension BattleTech.Equipment { return nil } + /// Resolves (or creates) the named tech base and sets this equipment's foreign key to it. + /// + /// - Parameters: + /// - techBase: Name of the tech base (e.g. Inner Sphere, Clan) to attach. + /// - database: The database connection to query and save through. + /// - Throws: Rethrows any Fluent query/save errors from ``BattleTech/TechBase/findOrCreate(tentativeTechBase:with:)``. func attachTechBase(techBase: String, on database: Database) async throws { let record = try await BattleTech.TechBase.findOrCreate( tentativeTechBase: techBase, @@ -76,6 +103,12 @@ extension BattleTech.Equipment { self.$techBase.id = record.id! } + /// Resolves (or creates) the named tech level and sets this equipment's foreign key to it. + /// + /// - Parameters: + /// - techLevel: Name of the tech level to attach. + /// - database: The database connection to query and save through. + /// - Throws: Rethrows any Fluent query/save errors from ``BattleTech/TechLevel/findOrCreate(tentativeTechLevel:with:)``. func attachTechLevel(techLevel: String, on database: Database) async throws { let record = try await BattleTech.TechLevel.findOrCreate( tentativeTechLevel: techLevel, @@ -85,6 +118,13 @@ extension BattleTech.Equipment { self.$techLevelStatic.id = record.id! } + /// Resolves (or creates) each named rule and attaches it to this equipment via the + /// many-to-many rules relationship, skipping any that are already attached. + /// + /// - Parameters: + /// - rules: Names of the rules to attach. + /// - database: The database connection to query and save through. + /// - Throws: Rethrows any Fluent query errors from ``BattleTech/Rule/findOrCreate(tentativeRules:with:)``. func attachRules(rules: [String], on database: Database) async throws { let records = try await BattleTech.Rule.findOrCreate( tentativeRules: rules, @@ -100,6 +140,13 @@ extension BattleTech.Equipment { } } + /// Resolves (or creates) each named alias and links it to this equipment record so the + /// alternate name can be used to find this equipment again in future imports. + /// + /// - Parameters: + /// - aliases: Alternate names to attach to this equipment. + /// - database: The database connection to query and save through. + /// - Throws: Rethrows any Fluent query errors from ``BattleTech/EquipmentAlias/findOrCreate(tentativeAliases:with:)``. func attachAliases(aliases: [String], on database: Database) async throws { let records = try await BattleTech.EquipmentAlias.findOrCreate( tentativeAliases: aliases, @@ -117,6 +164,13 @@ extension BattleTech.Equipment { } + /// Overwrites this equipment's scalar fields from the CSV row, saves it, then attaches + /// its tech base, tech level, rules, and aliases. + /// + /// - Parameters: + /// - csvRow: The parsed equipment CSV row supplying the new field values. + /// - database: The database connection to save and attach related records through. + /// - Throws: Rethrows any Fluent save error from persisting this record. func updateFromCSVRow(csvRow: Importers.EquipmentCSVRow, on database: Database) async throws { self.name = csvRow.name() self.techRating = csvRow.techRating() diff --git a/Sources/App/Models/BattleTech/Equipment.swift b/Sources/App/Models/BattleTech/Equipment.swift index 4bcb95e..c40b867 100644 --- a/Sources/App/Models/BattleTech/Equipment.swift +++ b/Sources/App/Models/BattleTech/Equipment.swift @@ -1,7 +1,16 @@ +/// Defines the ``BattleTech/Equipment`` Fluent model, which represents a single row in the +/// `equipment` table: general-purpose gear (e.g. heat sinks, jump jets, targeting computers) +/// as opposed to weapons or ammo, along with its tech-base and stat block. Served by the +/// public read-only BattleTech data API. import Fluent import Vapor extension BattleTech { + /// Fluent model backing the `equipment` table. + /// + /// Each row is one piece of non-weapon, non-ammo equipment sourced from MegaMek's data + /// files, linked to a ``BattleTech/TechBase``, a static ``BattleTech/TechLevel``, and any + /// applicable ``BattleTech/Rule``s and ``BattleTech/EquipmentAlias`` name aliases. final class Equipment: Model, Content, @unchecked Sendable { static let schema = BattleTech.Equipment.V20240330.schemaName public static let space: String? = BattleTech.Equipment.V20240330.spaceName @@ -12,18 +21,27 @@ extension BattleTech { @Field(key: BattleTech.Equipment.V20240330.name) var name: String + // `@Parent` declares a required foreign-key relationship: this equipment belongs to + // exactly one tech base record (Inner Sphere, Clan, etc). @Parent(key: BattleTech.Equipment.V20240330.techBase) var techBase: BattleTech.TechBase + // `@Siblings` exposes a many-to-many relationship through the `EquipmentRulePivot` + // join table: the optional/errata rulebook rules that apply to this equipment. @Siblings(through: BattleTech.EquipmentRulePivot.self, from: \.$equipment, to: \.$rule) var rules: [BattleTech.Rule] + // Tech rating (A-F scale) describing how advanced/rare the technology is. @Field(key: BattleTech.Equipment.V20240330.techRating) var techRating: String @Parent(key: BattleTech.Equipment.V20240330.techLevelStatic) var techLevelStatic: BattleTech.TechLevel + // In-universe timeline dates (as free-form strings) marking this equipment's + // lifecycle: when it was first designed, entered production, became common, went + // extinct, etc. `@OptionalField` means the column may be null since not every entry + // has every date. @OptionalField(key: BattleTech.Equipment.V20240330.introductionDate) var introductionDate: String? @@ -42,6 +60,8 @@ extension BattleTech { @OptionalField(key: BattleTech.Equipment.V20240330.reIntroductionDate) var reIntroductionDate: String? + // Core stat block: weight in tons, equipment slots consumed, C-bill cost, and + // Battle Value (the point-cost system used to balance forces). @Field(key: BattleTech.Equipment.V20240330.tonnage) var tonnage: Double @@ -57,22 +77,49 @@ extension BattleTech { @OptionalField(key: BattleTech.Equipment.V20240330.rulesReference) var rulesReference: String? + // `@Children` is the inverse of `@Parent`: all the alternate names this equipment is + // known by, used to match legacy/varied naming when importing CSV data. @Children(for: \.$equipment) var aliases: [BattleTech.EquipmentAlias] @Field(key: BattleTech.Equipment.V20240330.publishedAt) var publishedAt: Date? + // `@Timestamp` fields are managed automatically by Fluent: set on insert / on update. @Timestamp(key: BattleTech.Equipment.V20240330.createdAt, on: .create) var createdAt: Date? @Timestamp(key: BattleTech.Equipment.V20240330.updatedAt, on: .update) var updatedAt: Date? + /// Creates an empty instance for Fluent to populate when reading from the database. + /// + /// Defaults `publishedAt` to 60 days ago so newly-created records are already public. init() { self.publishedAt = Calendar.current.date(byAdding: .day, value: -60, to: Date()) } + /// Full member-wise initializer used when constructing an equipment record directly + /// (e.g. in tests or data importers) with all of its stats and related records. + /// + /// - Parameters: + /// - id: Optional primary key; leave `nil` for a new, unsaved record. + /// - name: Display name of the equipment. + /// - techBase: The ``BattleTech/TechBase`` (e.g. Inner Sphere, Clan) this equipment belongs to. + /// - techRating: Tech rating (A-F scale) for how advanced/rare the tech is. + /// - techLevelStatic: The ``BattleTech/TechLevel`` this equipment is classified under. + /// - introductionDate: In-universe year this equipment was first introduced. + /// - prototypeDate: In-universe year this equipment existed as a prototype. + /// - productionDate: In-universe year mass production began. + /// - commonDate: In-universe year this equipment became commonly available. + /// - extinctionDate: In-universe year this equipment went extinct, if applicable. + /// - reIntroductionDate: In-universe year this equipment was reintroduced, if applicable. + /// - tonnage: Weight in tons. + /// - criticalSlots: Number of critical/equipment slots consumed. + /// - cost: Cost in C-bills. + /// - battleValue: Battle Value point cost used for force balancing. + /// - rulesReference: Rulebook page/section this equipment is defined in. + /// - publishedAt: When this record becomes visible via the public API; defaults to 60 days ago. init( id: UUID? = nil, name: String, diff --git a/Sources/App/Models/BattleTech/EquipmentAlias+Extensions.swift b/Sources/App/Models/BattleTech/EquipmentAlias+Extensions.swift index 10b440f..548490a 100644 --- a/Sources/App/Models/BattleTech/EquipmentAlias+Extensions.swift +++ b/Sources/App/Models/BattleTech/EquipmentAlias+Extensions.swift @@ -1,7 +1,17 @@ +/// Helper used by the CSV importer to resolve a batch of alias name strings into +/// ``BattleTech/EquipmentAlias`` records, reusing existing ones where possible. import Fluent import Vapor extension BattleTech.EquipmentAlias { + /// For each given name, returns the existing alias record with that name, or builds a + /// new (unsaved) one if none exists yet. + /// + /// - Parameters: + /// - tentativeAliases: The alias name strings to resolve. + /// - database: The database connection to query through. + /// - Returns: One ``BattleTech/EquipmentAlias`` per input name, in the same order. + /// - Throws: Rethrows any Fluent query errors encountered while looking up existing aliases. static func findOrCreate( tentativeAliases: [String], with database: Database diff --git a/Sources/App/Models/BattleTech/EquipmentAlias.swift b/Sources/App/Models/BattleTech/EquipmentAlias.swift index 0ccea7a..11ea2b1 100644 --- a/Sources/App/Models/BattleTech/EquipmentAlias.swift +++ b/Sources/App/Models/BattleTech/EquipmentAlias.swift @@ -1,7 +1,12 @@ +/// Defines the ``BattleTech/EquipmentAlias`` Fluent model: an alternate name for an equipment +/// record, used so CSV imports can match equipment entries that MegaMek's data files refer to +/// under different names across revisions. import Fluent import Vapor extension BattleTech { + /// Fluent model backing the `equipment_aliases` table. Each row is one alternate name + /// that points back to a single ``BattleTech/Equipment`` record. final class EquipmentAlias: Model, Content, @unchecked Sendable { static let schema = BattleTech.EquipmentAlias.V20240330.schemaName public static let space: String? = BattleTech.EquipmentAlias.V20240330.spaceName @@ -12,11 +17,19 @@ extension BattleTech { @Field(key: BattleTech.EquipmentAlias.V20240330.name) var name: String + // `@Parent` declares the required foreign key back to the equipment record this + // alias refers to. @Parent(key: BattleTech.EquipmentAlias.V20240330.equipment) var equipment: BattleTech.Equipment + /// Creates an empty instance for Fluent to populate when reading from the database. init() {} + /// Creates a new alias with the given name, not yet linked to an equipment record. + /// + /// - Parameters: + /// - id: Optional primary key; leave `nil` for a new, unsaved record. + /// - name: The alternate name to record. init(id: UUID? = nil, name: String) { self.id = id self.name = name diff --git a/Sources/App/Models/BattleTech/EquipmentRulePivot.swift b/Sources/App/Models/BattleTech/EquipmentRulePivot.swift index d3fb59b..88998d7 100644 --- a/Sources/App/Models/BattleTech/EquipmentRulePivot.swift +++ b/Sources/App/Models/BattleTech/EquipmentRulePivot.swift @@ -1,7 +1,14 @@ +/// Defines the ``BattleTech/EquipmentRulePivot`` Fluent model: the join table implementing +/// the many-to-many relationship between equipment entries and the rulebook rules that apply +/// to them. import Fluent import Vapor extension BattleTech { + /// Fluent pivot model backing the `equipment_rule_pivot` table. Each row links one + /// ``BattleTech/Equipment`` record to one ``BattleTech/Rule`` record; used by + /// `Equipment.rules` (declared via `@Siblings`) to look up which rules apply to a given + /// piece of equipment. final class EquipmentRulePivot: Model, Content, @unchecked Sendable { static let schema = BattleTech.EquipmentRulePivot.V20240330.schemaName public static let space: String? = BattleTech.EquipmentRulePivot.V20240330.spaceName @@ -15,6 +22,7 @@ extension BattleTech { @Parent(key: BattleTech.EquipmentRulePivot.V20240330.equipment) var equipment: BattleTech.Equipment + /// Creates an empty instance for Fluent to populate when reading from the database. init() {} } } diff --git a/Sources/App/Models/BattleTech/Era.swift b/Sources/App/Models/BattleTech/Era.swift index d204e1c..b48210d 100644 --- a/Sources/App/Models/BattleTech/Era.swift +++ b/Sources/App/Models/BattleTech/Era.swift @@ -1,7 +1,18 @@ +/// Fluent model backing the `eras` table. +/// +/// A named historical period on the BattleTech timeline (e.g. Star League, +/// Succession Wars, Clan Invasion) used throughout the API to tag when +/// equipment, factions, and rules were introduced or in use. import Fluent import Vapor +// Extends the shared BattleTech namespace with the Era model. extension BattleTech { + /// One BattleTech era. + /// + /// A start/end year range identified by a short `code` and human-readable + /// `name`, plus display assets (`flag`, `icon`) and the id it maps to in + /// MegaMek's Master Unit List (MUL). final class Era: Model, Content, @unchecked Sendable { static let schema = BattleTech.Era.V20240212.schemaName public static let space: String? = BattleTech.Era.V20240212.spaceName @@ -9,27 +20,39 @@ extension BattleTech { @ID(key: .id) var id: UUID? + // @Field marks a required (non-null) column; @OptionalField (below) marks a + // nullable one. Both take the versioned column-key constants (e.g. + // `V20240212.code`) defined in the migration files rather than raw strings. @Field(key: BattleTech.Era.V20240212.code) var code: String @Field(key: BattleTech.Era.V20240212.name) var name: String + // startYear was added in a later migration (V20240409), so it's optional to + // remain compatible with rows created before that column existed. @OptionalField(key: BattleTech.Era.V20240409.startYear) var startYear: Int? @Field(key: BattleTech.Era.V20240212.endYear) var endYear: Int + // Display assets used by API consumers to render the era (flag image key, + // optional icon). @Field(key: BattleTech.Era.V20240212.flag) var flag: String @OptionalField(key: BattleTech.Era.V20240212.icon) var icon: String? + // External identifier linking this row to its corresponding entry in + // MegaMek's Master Unit List (MUL). @Field(key: BattleTech.Era.V20240212.mulId) var mulId: Int + // Publication/audit timestamps. `publishedAt` gates visibility (defaults to + // 60 days in the past, see init below); `@Timestamp` has Fluent set + // createdAt/updatedAt automatically on save/update. @Field(key: BattleTech.Era.V20240212.publishedAt) var publishedAt: Date? @@ -39,8 +62,25 @@ extension BattleTech { @Timestamp(key: BattleTech.Era.V20240212.updatedAt, on: .update) var updatedAt: Date? + /// Empty initializer required by Fluent to hydrate model instances fetched + /// from the database. init() {} + /// Creates a new Era, defaulting `publishedAt` to 60 days ago so it is + /// immediately visible through any "published" visibility filter. + /// + /// - Parameters: + /// - id: Optional database identifier; `nil` for a not-yet-persisted era. + /// - code: Short machine-readable code for the era. + /// - name: Human-readable display name. + /// - startYear: First year the era covers, or `-1` if unknown. + /// - endYear: Last year the era covers, or `-1` if unknown. + /// - flag: Key identifying the flag image asset for this era. + /// - icon: Optional key identifying an icon asset for this era. + /// - mulId: Identifier of the matching entry in MegaMek's Master Unit + /// List (MUL), or `-1` if unmapped. + /// - publishedAt: When the era becomes visible via the public API; + /// defaults to 60 days before now. init( id: UUID? = nil, code: String, diff --git a/Sources/App/Models/BattleTech/Faction.swift b/Sources/App/Models/BattleTech/Faction.swift index 24fff51..d8d4adb 100644 --- a/Sources/App/Models/BattleTech/Faction.swift +++ b/Sources/App/Models/BattleTech/Faction.swift @@ -1,7 +1,20 @@ +/// Fluent model backing the `factions` table. +/// +/// A BattleTech political/military power (e.g. Federated Suns, Draconis +/// Combine, a Clan) that fields units and controls territory. Factions can +/// have sub-factions (e.g. a Clan's constituent touman or a house's periphery +/// states), modeled below as a self-referencing many-to-many relationship. import Fluent import Vapor +// Extends the shared BattleTech namespace with the Faction model. extension BattleTech { + /// One BattleTech faction. + /// + /// Tracks whether it is a Clan power, a minor/periphery power, the Battle + /// Value equipment rating levels it uses, its display names over time + /// (``BattleTech/FactionName``), and its sub-faction/parent-faction + /// relationships. final class Faction: Model, Content, @unchecked Sendable { static let schema = BattleTech.Faction.V20240316.schemaName public static let space: String? = BattleTech.Faction.V20240316.spaceName @@ -12,6 +25,8 @@ extension BattleTech { @Field(key: BattleTech.Faction.V20240316.factionKey) var factionKey: String + // Classification flags: is this a minor faction, a Clan (as opposed to + // Inner Sphere), and/or a periphery (frontier, non-Great-House) power. @Field(key: BattleTech.Faction.V20240316.minor) var minor: Bool @@ -24,9 +39,17 @@ extension BattleTech { @Field(key: BattleTech.Faction.V20240316.ratingLevels) var ratingLevels: String + // @Children declares the "one" side of a one-to-many relationship: each + // BattleTech.FactionName row points back at this faction via its own + // @Parent. @Children(for: \.$faction) var names: [BattleTech.FactionName] + // @Siblings declares a many-to-many relationship backed by a pivot (join) + // table — here, BattleTech.FactionSubfactionPivot — instead of a foreign + // key on either side. Because a Faction can itself be another Faction's + // sub-faction, this relation is self-referencing: `subfactions` walks the + // pivot in one direction, `parents` walks it in the other. @Siblings( through: BattleTech.FactionSubfactionPivot.self, from: \.$faction, @@ -50,8 +73,24 @@ extension BattleTech { @Timestamp(key: BattleTech.Faction.V20240316.updatedAt, on: .update) var updatedAt: Date? + /// Empty initializer required by Fluent to hydrate model instances fetched + /// from the database. init() {} + /// Creates a new Faction, defaulting `publishedAt` to 60 days ago so it is + /// immediately visible through any "published" visibility filter. + /// + /// - Parameters: + /// - id: Optional database identifier; `nil` for a not-yet-persisted + /// faction. + /// - factionKey: Unique machine-readable key for this faction. + /// - minor: Whether this is a minor faction rather than a major power. + /// - clan: Whether this is a Clan faction (as opposed to Inner Sphere). + /// - periphery: Whether this is a periphery (frontier) power. + /// - ratingLevels: The Battle Value equipment rating levels this + /// faction uses. + /// - publishedAt: When the faction becomes visible via the public + /// API; defaults to 60 days before now. init( id: UUID? = nil, factionKey: String, diff --git a/Sources/App/Models/BattleTech/FactionName.swift b/Sources/App/Models/BattleTech/FactionName.swift index 0b92be4..848bc9f 100644 --- a/Sources/App/Models/BattleTech/FactionName.swift +++ b/Sources/App/Models/BattleTech/FactionName.swift @@ -1,7 +1,18 @@ +/// Fluent model backing the `faction_names` table. +/// +/// A historical display name for a faction over a given year range. Factions +/// are renamed or rebranded over the course of BattleTech's timeline (e.g. +/// after a merger or civil war), so a ``BattleTech/Faction`` can own several +/// of these rather than a single fixed `name` field. import Fluent import Vapor +// Extends the shared BattleTech namespace with the FactionName model. extension BattleTech { + /// One name a faction was known by during a particular era. + /// + /// Stores the optional start/end years it applied and an optional + /// display image. final class FactionName: Model, Content, @unchecked Sendable { static let schema = BattleTech.FactionName.V20240316.schemaName public static let space: String? = BattleTech.FactionName.V20240316.spaceName @@ -9,6 +20,8 @@ extension BattleTech { @ID(key: .id) var id: UUID? + // The name text and the year range it was in use; both bounds are + // optional since some names have no recorded start/end. @Field(key: BattleTech.FactionName.V20240316.name) var name: String @@ -18,14 +31,29 @@ extension BattleTech { @OptionalField(key: BattleTech.FactionName.V20240316.endYear) var endYear: Int? + // @Parent declares the "many" side of a one-to-many relationship: this + // stores the owning BattleTech.Faction's id as a foreign key, mirroring + // the @Children collection on BattleTech.Faction.names. @Parent(key: BattleTech.FactionName.V20240316.faction) var faction: BattleTech.Faction @OptionalField(key: BattleTech.FactionName.V20240415.image) var image: String? + /// Empty initializer required by Fluent to hydrate model instances fetched + /// from the database. init() {} + /// Creates a new FactionName. The `faction` relationship is set + /// separately (e.g. via `$faction.id`) after construction. + /// + /// - Parameters: + /// - id: Optional database identifier; `nil` for a not-yet-persisted + /// name. + /// - name: The display name text. + /// - startYear: First year this name was in use, if known. + /// - endYear: Last year this name was in use, if known. + /// - image: Optional key identifying a display image asset. init( id: UUID? = nil, name: String, diff --git a/Sources/App/Models/BattleTech/FactionSubfactionPivot.swift b/Sources/App/Models/BattleTech/FactionSubfactionPivot.swift index ef441e5..61f75b5 100644 --- a/Sources/App/Models/BattleTech/FactionSubfactionPivot.swift +++ b/Sources/App/Models/BattleTech/FactionSubfactionPivot.swift @@ -1,7 +1,17 @@ +/// Fluent model backing the `faction_subfaction` pivot table. +/// +/// A pivot (join) table implements a many-to-many relationship by holding one +/// row per pairing instead of a foreign key on either side. This one links a +/// ``BattleTech/Faction`` to another ``BattleTech/Faction`` that is +/// subordinate to it, letting a single faction have many sub-factions and +/// belong to many parent factions. Consumed by the `subfactions`/`parents` +/// `@Siblings` relationships on ``BattleTech/Faction``. import Fluent import Vapor +// Extends the shared BattleTech namespace with the FactionSubfactionPivot model. extension BattleTech { + /// One faction/sub-faction pairing row in the pivot table. final class FactionSubfactionPivot: Model, Content, @unchecked Sendable { static let schema = BattleTech.FactionSubfactionPivot.V20240316.schemaName public static let space: String? = BattleTech.FactionSubfactionPivot.V20240316.spaceName @@ -9,12 +19,16 @@ extension BattleTech { @ID(key: .id) var id: UUID? + // The two sides of the relationship: the parent faction and the + // subordinate faction it contains. @Parent(key: BattleTech.FactionSubfactionPivot.V20240316.faction) var faction: BattleTech.Faction @Parent(key: BattleTech.FactionSubfactionPivot.V20240316.subfaction) var subfaction: BattleTech.Faction + /// Empty initializer required by Fluent to hydrate model instances + /// fetched from the database. init() {} } } diff --git a/Sources/App/Models/BattleTech/MunitionType+Extension.swift b/Sources/App/Models/BattleTech/MunitionType+Extension.swift index 827c56e..da8e249 100644 --- a/Sources/App/Models/BattleTech/MunitionType+Extension.swift +++ b/Sources/App/Models/BattleTech/MunitionType+Extension.swift @@ -1,7 +1,17 @@ +/// Helper used by the CSV importer to resolve a munition type name string into a +/// ``BattleTech/MunitionType`` record, reusing an existing one where possible. import Fluent import Vapor extension BattleTech.MunitionType { + /// Returns the existing munition type record with the given name, or creates and saves + /// a new one if none exists yet. + /// + /// - Parameters: + /// - tentativeMunitionType: The munition type name to resolve (e.g. "Standard"). + /// - database: The database connection to query and save through. + /// - Returns: The found-or-created ``BattleTech/MunitionType`` record. + /// - Throws: Rethrows any Fluent query error encountered while looking up the existing record. static func findOrCreate( tentativeMunitionType: String, with database: Database diff --git a/Sources/App/Models/BattleTech/MunitionType.swift b/Sources/App/Models/BattleTech/MunitionType.swift index dc72832..eb131d3 100644 --- a/Sources/App/Models/BattleTech/MunitionType.swift +++ b/Sources/App/Models/BattleTech/MunitionType.swift @@ -1,7 +1,12 @@ +/// Defines the ``BattleTech/MunitionType`` Fluent model: the category of munition an ammo +/// entry loads (e.g. Standard, Inferno, Cluster, Armor-Piercing), shared by every ``BattleTech/Ammo`` +/// record of that kind. import Fluent import Vapor extension BattleTech { + /// Fluent model backing the `munition_types` table. Each row is one distinct munition + /// type name, referenced by any number of ``BattleTech/Ammo`` records. final class MunitionType: Model, Content, @unchecked Sendable { static let schema = BattleTech.MunitionType.V20240327.schemaName public static let space: String? = BattleTech.MunitionType.V20240327.spaceName @@ -12,11 +17,19 @@ extension BattleTech { @Field(key: BattleTech.MunitionType.V20240327.name) var name: String + // `@Children` is the inverse of `Ammo`'s `@Parent` relationship: every ammo record + // that uses this munition type. @Children(for: \.$munitionType) var ammo: [BattleTech.Ammo] + /// Creates an empty instance for Fluent to populate when reading from the database. init() {} + /// Creates a new munition type with the given name. + /// + /// - Parameters: + /// - id: Optional primary key; leave `nil` for a new, unsaved record. + /// - name: The munition type name (e.g. "Standard", "Inferno"). init(id: UUID? = nil, name: String) { self.id = id self.name = name diff --git a/Sources/App/Models/BattleTech/Rule+Extensions.swift b/Sources/App/Models/BattleTech/Rule+Extensions.swift index 6b0515d..832f8c8 100644 --- a/Sources/App/Models/BattleTech/Rule+Extensions.swift +++ b/Sources/App/Models/BattleTech/Rule+Extensions.swift @@ -1,7 +1,27 @@ +/// Import-time helper for resolving ``BattleTech/Rule`` rows by name. +/// +/// Used when ingesting MegaMek data files, where rules are referenced by +/// their name string and may or may not already exist in the database. import Fluent import Vapor extension BattleTech.Rule { + /// Looks up each named rule, creating and persisting any that don't + /// already exist, so callers always get back a fully-populated rule for + /// every name given. + /// + /// Save failures for newly created rules are logged rather than thrown, + /// so a bad row does not abort the whole batch; the rule is still + /// returned unsaved in that case. + /// + /// - Parameters: + /// - tentativeRules: Rule names to look up or create, e.g. as parsed + /// from a MegaMek data file. + /// - database: The database connection to query and save against. + /// - Returns: One ``BattleTech/Rule`` per entry in `tentativeRules`, in + /// the same order, either fetched from existing rows or newly created. + /// - Throws: Any error from the underlying query (save errors on new + /// rules are caught and logged, not thrown). static func findOrCreate(tentativeRules: [String], with database: Database) async throws -> [BattleTech.Rule] { var rules: [BattleTech.Rule] = [] diff --git a/Sources/App/Models/BattleTech/Rule.swift b/Sources/App/Models/BattleTech/Rule.swift index 33e46d0..2add45d 100644 --- a/Sources/App/Models/BattleTech/Rule.swift +++ b/Sources/App/Models/BattleTech/Rule.swift @@ -1,7 +1,16 @@ +/// Fluent model backing the `rules` table. +/// +/// A named tabletop ruleset/tag (e.g. "Standard", "Advanced", "Introductory") +/// that governs which pieces of equipment are legal to use together. Rules +/// are linked to ``BattleTech/Ammo``, `Equipment`, and ``BattleTech/Weapon`` +/// through pivot tables, since any of those can be tagged with many rules and +/// any rule can apply to many items. import Fluent import Vapor +// Extends the shared BattleTech namespace with the Rule model. extension BattleTech { + /// One named ruleset that applies to a set of ammo, equipment, and weapons. final class Rule: Model, Content, @unchecked Sendable { static let schema = BattleTech.Rule.V20240316.schemaName public static let space: String? = BattleTech.Rule.V20240316.spaceName @@ -12,6 +21,8 @@ extension BattleTech { @Field(key: BattleTech.Rule.V20240316.name) var name: String + // Many-to-many relationships to the item types this rule applies to, + // each backed by its own pivot table (e.g. BattleTech.RuleWeaponPivot). @Siblings(through: BattleTech.AmmoRulePivot.self, from: \.$rule, to: \.$ammo) var ammo: [BattleTech.Ammo] @@ -21,8 +32,16 @@ extension BattleTech { @Siblings(through: BattleTech.RuleWeaponPivot.self, from: \.$rule, to: \.$weapon) var weapons: [BattleTech.Weapon] + /// Empty initializer required by Fluent to hydrate model instances + /// fetched from the database. init() {} + /// Creates a new Rule with the given name. + /// + /// - Parameters: + /// - id: Optional database identifier; `nil` for a not-yet-persisted + /// rule. + /// - name: The rule's unique display name. init(id: UUID? = nil, name: String) { self.id = id self.name = name diff --git a/Sources/App/Models/BattleTech/RuleWeaponPivot.swift b/Sources/App/Models/BattleTech/RuleWeaponPivot.swift index 17e0874..85cbeb6 100644 --- a/Sources/App/Models/BattleTech/RuleWeaponPivot.swift +++ b/Sources/App/Models/BattleTech/RuleWeaponPivot.swift @@ -1,7 +1,16 @@ +/// Fluent model backing the `rule_weapon` pivot table. +/// +/// Pivot (join) table implementing the many-to-many relationship between +/// ``BattleTech/Rule`` and ``BattleTech/Weapon``: one row per rule/weapon +/// pairing, since a rule can apply to many weapons and a weapon can be +/// governed by many rules. Consumed by the `weapons` `@Siblings` relationship +/// on ``BattleTech/Rule``. import Fluent import Vapor +// Extends the shared BattleTech namespace with the RuleWeaponPivot model. extension BattleTech { + /// One rule/weapon pairing row in the pivot table. final class RuleWeaponPivot: Model, Content, @unchecked Sendable { static let schema = BattleTech.RuleWeaponPivot.V20240316.schemaName public static let space: String? = BattleTech.RuleWeaponPivot.V20240316.spaceName @@ -9,12 +18,16 @@ extension BattleTech { @ID(key: .id) var id: UUID? + // The two sides of the relationship: the rule and the weapon it + // applies to. @Parent(key: BattleTech.RuleWeaponPivot.V20240316.rule) var rule: BattleTech.Rule @Parent(key: BattleTech.RuleWeaponPivot.V20240316.weapon) var weapon: BattleTech.Weapon + /// Empty initializer required by Fluent to hydrate model instances + /// fetched from the database. init() {} } } diff --git a/Sources/App/Models/BattleTech/TechBase+Extension.swift b/Sources/App/Models/BattleTech/TechBase+Extension.swift index ebb90c2..1462835 100644 --- a/Sources/App/Models/BattleTech/TechBase+Extension.swift +++ b/Sources/App/Models/BattleTech/TechBase+Extension.swift @@ -1,7 +1,26 @@ +/// Import-time helper for resolving a ``BattleTech/TechBase`` row by name. +/// +/// Used when ingesting MegaMek data files, where a tech base (e.g. "Inner +/// Sphere", "Clan") is referenced by name and may or may not already exist in +/// the database. import Fluent import Vapor extension BattleTech.TechBase { + /// Looks up the named tech base, creating and persisting it if it doesn't + /// already exist. + /// + /// A save failure for a newly created tech base is logged rather than + /// thrown, so the caller still receives an (unsaved) instance rather than + /// an aborted import. + /// + /// - Parameters: + /// - tentativeTechBase: The tech base name to look up or create, e.g. + /// as parsed from a MegaMek data file. + /// - database: The database connection to query and save against. + /// - Returns: The matching or newly created ``BattleTech/TechBase``. + /// - Throws: Any error from the underlying query (save errors on a new + /// tech base are caught and logged, not thrown). static func findOrCreate(tentativeTechBase: String, with database: Database) async throws -> BattleTech.TechBase { if let foundTechBase = try await BattleTech.TechBase.query(on: database) diff --git a/Sources/App/Models/BattleTech/TechBase.swift b/Sources/App/Models/BattleTech/TechBase.swift index 83dc036..6c0185e 100644 --- a/Sources/App/Models/BattleTech/TechBase.swift +++ b/Sources/App/Models/BattleTech/TechBase.swift @@ -1,7 +1,17 @@ +/// Fluent model backing the `tech_bases` table. +/// +/// A tech base identifies which technological lineage a piece of equipment +/// comes from — typically "Inner Sphere" or "Clan" (the two major BattleTech +/// tech traditions), though mixed-tech variants also exist. Every +/// ``BattleTech/Ammo``, `Equipment`, and ``BattleTech/Weapon`` row references +/// one tech base. import Fluent import Vapor +// Extends the shared BattleTech namespace with the TechBase model. extension BattleTech { + /// One tech base (e.g. Inner Sphere or Clan) and the equipment items that + /// belong to it. final class TechBase: Model, Content, @unchecked Sendable { static let schema = BattleTech.TechBase.V20240316.schemaName public static let space: String? = BattleTech.TechBase.V20240316.spaceName @@ -12,6 +22,8 @@ extension BattleTech { @Field(key: BattleTech.TechBase.V20240316.name) var name: String + // @Children declares the "one" side of a one-to-many relationship: + // each item row points back at this tech base via its own @Parent. @Children(for: \.$techBase) var ammo: [BattleTech.Ammo] @@ -21,8 +33,17 @@ extension BattleTech { @Children(for: \.$techBase) var weapons: [BattleTech.Weapon] + /// Empty initializer required by Fluent to hydrate model instances + /// fetched from the database. init() {} + /// Creates a new TechBase with the given name. + /// + /// - Parameters: + /// - id: Optional database identifier; `nil` for a not-yet-persisted + /// tech base. + /// - name: The tech base's unique display name (e.g. "Inner Sphere", + /// "Clan"). init(id: UUID? = nil, name: String) { self.id = id self.name = name diff --git a/Sources/App/Models/BattleTech/TechLevel+Extensions.swift b/Sources/App/Models/BattleTech/TechLevel+Extensions.swift index 6da0528..7ca38b4 100644 --- a/Sources/App/Models/BattleTech/TechLevel+Extensions.swift +++ b/Sources/App/Models/BattleTech/TechLevel+Extensions.swift @@ -1,7 +1,26 @@ +/// Import-time helper for resolving a ``BattleTech/TechLevel`` row by name. +/// +/// Used when ingesting MegaMek data files, where a tech level (e.g. +/// "Standard", "Advanced") is referenced by name and may or may not already +/// exist in the database. import Fluent import Vapor extension BattleTech.TechLevel { + /// Looks up the named tech level, creating and persisting it if it + /// doesn't already exist. + /// + /// A save failure for a newly created tech level is logged rather than + /// thrown, so the caller still receives an (unsaved) instance rather than + /// an aborted import. + /// + /// - Parameters: + /// - tentativeTechLevel: The tech level name to look up or create, e.g. + /// as parsed from a MegaMek data file. + /// - database: The database connection to query and save against. + /// - Returns: The matching or newly created ``BattleTech/TechLevel``. + /// - Throws: Any error from the underlying query (save errors on a new + /// tech level are caught and logged, not thrown). static func findOrCreate(tentativeTechLevel: String, with database: Database) async throws -> BattleTech.TechLevel { if let foundTechLevel = try await BattleTech.TechLevel.query(on: database) diff --git a/Sources/App/Models/BattleTech/TechLevel.swift b/Sources/App/Models/BattleTech/TechLevel.swift index 3e39b52..ed9151d 100644 --- a/Sources/App/Models/BattleTech/TechLevel.swift +++ b/Sources/App/Models/BattleTech/TechLevel.swift @@ -1,7 +1,17 @@ +/// Fluent model backing the `tech_levels` table. +/// +/// A tech level classifies how advanced/restricted a piece of equipment is +/// for tabletop play (e.g. "Introductory", "Standard", "Advanced", +/// "Experimental"), independent of its ``BattleTech/TechBase`` (Inner Sphere +/// vs. Clan). Every ``BattleTech/Ammo``, `Equipment`, and ``BattleTech/Weapon`` +/// row references one tech level. import Fluent import Vapor +// Extends the shared BattleTech namespace with the TechLevel model. extension BattleTech { + /// One tech level (e.g. Standard, Advanced) and the equipment items + /// classified under it. final class TechLevel: Model, Content, @unchecked Sendable { static let schema = BattleTech.TechLevel.V20240316.schemaName public static let space: String? = BattleTech.TechLevel.V20240316.spaceName @@ -12,6 +22,9 @@ extension BattleTech { @Field(key: BattleTech.TechLevel.V20240316.name) var name: String + // @Children declares the "one" side of a one-to-many relationship: + // each item row points back at this tech level (its "static", i.e. + // rules-defined, tech level) via its own @Parent. @Children(for: \.$techLevelStatic) var ammo: [BattleTech.Ammo] @@ -21,8 +34,17 @@ extension BattleTech { @Children(for: \.$techLevelStatic) var weapons: [BattleTech.Weapon] + /// Empty initializer required by Fluent to hydrate model instances + /// fetched from the database. init() {} + /// Creates a new TechLevel with the given name. + /// + /// - Parameters: + /// - id: Optional database identifier; `nil` for a not-yet-persisted + /// tech level. + /// - name: The tech level's unique display name (e.g. "Standard", + /// "Advanced"). init(id: UUID? = nil, name: String) { self.id = id self.name = name diff --git a/Sources/App/Models/BattleTech/Weapon+Extension.swift b/Sources/App/Models/BattleTech/Weapon+Extension.swift index 6b956d2..2108cea 100644 --- a/Sources/App/Models/BattleTech/Weapon+Extension.swift +++ b/Sources/App/Models/BattleTech/Weapon+Extension.swift @@ -1,7 +1,17 @@ +/// Helper methods used by the CSV data importer to create/update ``BattleTech/Weapon`` +/// records and their related tech-base, tech-level, rule, and alias associations. import Fluent import Vapor extension BattleTech.Weapon { + /// Finds an existing weapon record matching the CSV row, or creates a new one, then + /// overwrites it with the row's data and persists it. + /// + /// - Parameters: + /// - csvRow: The parsed weapon CSV row to import. + /// - database: The database connection to query and save through. + /// - Returns: The found-or-created, now up-to-date ``BattleTech/Weapon`` record. + /// - Throws: Rethrows any Fluent query/save errors encountered while matching or updating. static func findOrCreate(csvRow: Importers.WeaponCSVRow, on database: Database) async throws -> BattleTech.Weapon { var weapon: BattleTech.Weapon? = BattleTech.Weapon() @@ -25,6 +35,17 @@ extension BattleTech.Weapon { return weapon! } + /// Looks up a weapon record whose name matches exactly, or whose name matches one of the + /// given aliases, while also matching on tech rating, tonnage, tech level, and tech base + /// (resolving/creating those lookup records as needed). + /// + /// - Parameters: + /// - name: The weapon name to match exactly. + /// - aliases: Alternate names to match against if an exact name match isn't found. + /// - csvRow: The CSV row supplying the tech rating, tonnage, tech level, and tech base to match on. + /// - database: The database connection to query through. + /// - Returns: The matching ``BattleTech/Weapon`` record, or `nil` if none matches. + /// - Throws: Rethrows any Fluent query errors encountered while resolving lookups or matching. static func findByNameOrAliases( name: String, aliases: [String], @@ -65,6 +86,12 @@ extension BattleTech.Weapon { return nil } + /// Resolves (or creates) the named tech base and sets this weapon's foreign key to it. + /// + /// - Parameters: + /// - techBase: Name of the tech base (e.g. Inner Sphere, Clan) to attach. + /// - database: The database connection to query and save through. + /// - Throws: Rethrows any Fluent query/save errors from ``BattleTech/TechBase/findOrCreate(tentativeTechBase:with:)``. func attachTechBase(techBase: String, on database: Database) async throws { let record = try await BattleTech.TechBase.findOrCreate( tentativeTechBase: techBase, @@ -74,6 +101,12 @@ extension BattleTech.Weapon { self.$techBase.id = record.id! } + /// Resolves (or creates) the named tech level and sets this weapon's foreign key to it. + /// + /// - Parameters: + /// - techLevel: Name of the tech level to attach. + /// - database: The database connection to query and save through. + /// - Throws: Rethrows any Fluent query/save errors from ``BattleTech/TechLevel/findOrCreate(tentativeTechLevel:with:)``. func attachTechLevel(techLevel: String, on database: Database) async throws { let record = try await BattleTech.TechLevel.findOrCreate( tentativeTechLevel: techLevel, @@ -83,6 +116,13 @@ extension BattleTech.Weapon { self.$techLevelStatic.id = record.id! } + /// Resolves (or creates) each named rule and attaches it to this weapon via the + /// many-to-many rules relationship, skipping any that are already attached. + /// + /// - Parameters: + /// - rules: Names of the rules to attach. + /// - database: The database connection to query and save through. + /// - Throws: Rethrows any Fluent query errors from ``BattleTech/Rule/findOrCreate(tentativeRules:with:)``. func attachRules(rules: [String], on database: Database) async throws { let records = try await BattleTech.Rule.findOrCreate( tentativeRules: rules, @@ -99,6 +139,13 @@ extension BattleTech.Weapon { } } + /// Resolves (or creates) each named alias and links it to this weapon record so the + /// alternate name can be used to find this weapon again in future imports. + /// + /// - Parameters: + /// - aliases: Alternate names to attach to this weapon. + /// - database: The database connection to query and save through. + /// - Throws: Rethrows any Fluent query errors from ``BattleTech/WeaponAlias/findOrCreate(tentativeAliases:with:)``. func attachAliases(aliases: [String], on database: Database) async throws { let records = try await BattleTech.WeaponAlias.findOrCreate( tentativeAliases: aliases, @@ -118,6 +165,13 @@ extension BattleTech.Weapon { } + /// Overwrites this weapon's scalar fields from the CSV row, saves it, then attaches its + /// tech base, tech level, rules, and aliases. + /// + /// - Parameters: + /// - csvRow: The parsed weapon CSV row supplying the new field values. + /// - database: The database connection to save and attach related records through. + /// - Throws: Rethrows any Fluent save error from persisting this record. func updateFromCSVRow(csvRow: Importers.WeaponCSVRow, on database: Database) async throws { self.name = csvRow.name() self.techRating = csvRow.techRating() diff --git a/Sources/App/Models/BattleTech/Weapon.swift b/Sources/App/Models/BattleTech/Weapon.swift index dcabf98..2bf8b1b 100644 --- a/Sources/App/Models/BattleTech/Weapon.swift +++ b/Sources/App/Models/BattleTech/Weapon.swift @@ -1,7 +1,16 @@ +/// Defines the ``BattleTech/Weapon`` Fluent model, which represents a single row in the +/// `weapons` table: one weapon system (e.g. "Medium Laser", "LRM 20") along with its +/// tech-base, stat block, and per-range-bracket damage/range figures. This is the +/// source-of-truth record served by the public read-only BattleTech data API. import Fluent import Vapor extension BattleTech { + /// Fluent model backing the `weapons` table. + /// + /// Each row is one weapon entry sourced from MegaMek's data files, linked to a + /// ``BattleTech/TechBase``, a static ``BattleTech/TechLevel``, and any applicable + /// ``BattleTech/Rule``s and ``BattleTech/WeaponAlias`` name aliases. final class Weapon: Model, Content, @unchecked Sendable { static let schema = BattleTech.Weapon.V20240316.schemaName public static let space: String? = BattleTech.Weapon.V20240316.spaceName @@ -12,18 +21,26 @@ extension BattleTech { @Field(key: BattleTech.Weapon.V20240316.name) var name: String + // `@Parent` declares a required foreign-key relationship: this weapon belongs to + // exactly one tech base record (Inner Sphere, Clan, etc). @Parent(key: BattleTech.Weapon.V20240316.techBase) var techBase: BattleTech.TechBase + // `@Siblings` exposes a many-to-many relationship through the `RuleWeaponPivot` join + // table: the optional/errata rulebook rules that apply to this weapon. @Siblings(through: BattleTech.RuleWeaponPivot.self, from: \.$weapon, to: \.$rule) var rules: [BattleTech.Rule] + // Tech rating (A-F scale) describing how advanced/rare the technology is. @Field(key: BattleTech.Weapon.V20240316.techRating) var techRating: String @Parent(key: BattleTech.Weapon.V20240316.techLevelStatic) var techLevelStatic: BattleTech.TechLevel + // In-universe timeline dates (as free-form strings) marking this weapon's lifecycle: + // when it was first designed, entered production, became common, went extinct, etc. + // `@OptionalField` means the column may be null since not every entry has every date. @OptionalField(key: BattleTech.Weapon.V20240316.introductionDate) var introductionDate: String? @@ -42,6 +59,8 @@ extension BattleTech { @OptionalField(key: BattleTech.Weapon.V20240316.reIntroductionDate) var reIntroductionDate: String? + // Core stat block: weight in tons, equipment slots consumed, C-bill cost, and + // Battle Value (the point-cost system used to balance forces). @Field(key: BattleTech.Weapon.V20240316.tonnage) var tonnage: Double @@ -57,6 +76,8 @@ extension BattleTech { @OptionalField(key: BattleTech.Weapon.V20240316.rulesReference) var rulesReference: String? + // Range brackets, in hexes, at which this weapon can fire on land: minimal (a + // to-hit penalty applies below this range), short, medium, long, and extreme. @Field(key: BattleTech.Weapon.V20240316.minimalRange) var minimalRange: Int @@ -72,6 +93,7 @@ extension BattleTech { @Field(key: BattleTech.Weapon.V20240316.extremeRange) var extremeRange: Int + // Equivalent range brackets, in hexes, when firing underwater. @Field(key: BattleTech.Weapon.V20240316.shortWaterRange) var shortWaterRange: Int @@ -84,6 +106,7 @@ extension BattleTech { @Field(key: BattleTech.Weapon.V20240316.extremeWaterRange) var extremeWaterRange: Int + // Damage dealt at each range bracket, in points. @Field(key: BattleTech.Weapon.V20240316.minimalRangeDamage) var minimalRangeDamage: Int @@ -99,22 +122,63 @@ extension BattleTech { @Field(key: BattleTech.Weapon.V20240316.extremeRangeDamage) var extremeRangeDamage: Int + // `@Children` is the inverse of `@Parent`: all the alternate names this weapon is + // known by, used to match legacy/varied naming when importing CSV data. @Children(for: \.$weapon) var aliases: [BattleTech.WeaponAlias] @Field(key: BattleTech.Weapon.V20240316.publishedAt) var publishedAt: Date? + // `@Timestamp` fields are managed automatically by Fluent: set on insert / on update. @Timestamp(key: BattleTech.Weapon.V20240316.createdAt, on: .create) var createdAt: Date? @Timestamp(key: BattleTech.Weapon.V20240316.updatedAt, on: .update) var updatedAt: Date? + /// Creates an empty instance for Fluent to populate when reading from the database. + /// + /// Defaults `publishedAt` to 60 days ago so newly-created records are already public. init() { self.publishedAt = Calendar.current.date(byAdding: .day, value: -60, to: Date()) } + /// Full member-wise initializer used when constructing a weapon record directly + /// (e.g. in tests or data importers) with all of its stats and related records. + /// + /// - Parameters: + /// - id: Optional primary key; leave `nil` for a new, unsaved record. + /// - name: Display name of the weapon. + /// - techBase: The ``BattleTech/TechBase`` (e.g. Inner Sphere, Clan) this weapon belongs to. + /// - techRating: Tech rating (A-F scale) for how advanced/rare the tech is. + /// - techLevelStatic: The ``BattleTech/TechLevel`` this weapon is classified under. + /// - introductionDate: In-universe year this weapon was first introduced. + /// - prototypeDate: In-universe year this weapon existed as a prototype. + /// - productionDate: In-universe year mass production began. + /// - commonDate: In-universe year this weapon became commonly available. + /// - extinctionDate: In-universe year this weapon went extinct, if applicable. + /// - reIntroductionDate: In-universe year this weapon was reintroduced, if applicable. + /// - tonnage: Weight in tons. + /// - criticalSlots: Number of critical/equipment slots consumed. + /// - cost: Cost in C-bills. + /// - battleValue: Battle Value point cost used for force balancing. + /// - rulesReference: Rulebook page/section this weapon is defined in. + /// - minimalRange: Land minimal range, in hexes, below which a to-hit penalty applies. + /// - shortRange: Land short range bracket, in hexes. + /// - mediumRange: Land medium range bracket, in hexes. + /// - longRange: Land long range bracket, in hexes. + /// - extremeRange: Land extreme range bracket, in hexes. + /// - shortWaterRange: Underwater short range bracket, in hexes. + /// - mediumWaterRange: Underwater medium range bracket, in hexes. + /// - longWaterRange: Underwater long range bracket, in hexes. + /// - extremeWaterRange: Underwater extreme range bracket, in hexes. + /// - minimalRangeDamage: Damage dealt at minimal range. + /// - shortRangeDamage: Damage dealt at short range. + /// - mediumRangeDamage: Damage dealt at medium range. + /// - longRangeDamage: Damage dealt at long range. + /// - extremeRangeDamage: Damage dealt at extreme range. + /// - publishedAt: When this record becomes visible via the public API; defaults to 60 days ago. init( id: UUID? = nil, name: String, diff --git a/Sources/App/Models/BattleTech/WeaponAlias+Extensions.swift b/Sources/App/Models/BattleTech/WeaponAlias+Extensions.swift index 7ac4ab7..cd73d1e 100644 --- a/Sources/App/Models/BattleTech/WeaponAlias+Extensions.swift +++ b/Sources/App/Models/BattleTech/WeaponAlias+Extensions.swift @@ -1,7 +1,17 @@ +/// Helper used by the CSV importer to resolve a batch of alias name strings into +/// ``BattleTech/WeaponAlias`` records, reusing existing ones where possible. import Fluent import Vapor extension BattleTech.WeaponAlias { + /// For each given name, returns the existing alias record with that name, or builds a + /// new (unsaved) one if none exists yet. + /// + /// - Parameters: + /// - tentativeAliases: The alias name strings to resolve. + /// - database: The database connection to query through. + /// - Returns: One ``BattleTech/WeaponAlias`` per input name, in the same order. + /// - Throws: Rethrows any Fluent query errors encountered while looking up existing aliases. static func findOrCreate( tentativeAliases: [String], with database: Database diff --git a/Sources/App/Models/BattleTech/WeaponAlias.swift b/Sources/App/Models/BattleTech/WeaponAlias.swift index b7b5cbd..4bbf8ff 100644 --- a/Sources/App/Models/BattleTech/WeaponAlias.swift +++ b/Sources/App/Models/BattleTech/WeaponAlias.swift @@ -1,7 +1,12 @@ +/// Defines the ``BattleTech/WeaponAlias`` Fluent model: an alternate name for a weapon +/// record, used so CSV imports can match weapon entries that MegaMek's data files refer to +/// under different names across revisions. import Fluent import Vapor extension BattleTech { + /// Fluent model backing the `weapon_aliases` table. Each row is one alternate name that + /// points back to a single ``BattleTech/Weapon`` record. final class WeaponAlias: Model, Content, @unchecked Sendable { static let schema = BattleTech.WeaponAlias.V20240323.schemaName public static let space: String? = BattleTech.WeaponAlias.V20240323.spaceName @@ -12,11 +17,19 @@ extension BattleTech { @Field(key: BattleTech.WeaponAlias.V20240323.name) var name: String + // `@Parent` declares the required foreign key back to the weapon record this alias + // refers to. @Parent(key: BattleTech.WeaponAlias.V20240323.weapon) var weapon: BattleTech.Weapon + /// Creates an empty instance for Fluent to populate when reading from the database. init() {} + /// Creates a new alias with the given name, not yet linked to a weapon record. + /// + /// - Parameters: + /// - id: Optional primary key; leave `nil` for a new, unsaved record. + /// - name: The alternate name to record. init(id: UUID? = nil, name: String) { self.id = id self.name = name diff --git a/Sources/App/Models/MegaMek.swift b/Sources/App/Models/MegaMek.swift new file mode 100644 index 0000000..a684155 --- /dev/null +++ b/Sources/App/Models/MegaMek.swift @@ -0,0 +1,13 @@ +// +// MegaMek.swift +// mul-api +// +// Created by Richard Hancock on 5/9/25. +// + +/// Empty namespacing type for models related to MegaMek client/server installations that +/// announce themselves to this API (e.g. ``MegaMek/Server``, ``MegaMek/ServerDTO``). +/// +/// Like ``Security``, it has no members of its own — it exists only so related types can be +/// written as `MegaMek.Server` etc. instead of living in the global namespace. +struct MegaMek {} diff --git a/Sources/App/Models/MegaMek/Server.swift b/Sources/App/Models/MegaMek/Server.swift new file mode 100644 index 0000000..6b32b9f --- /dev/null +++ b/Sources/App/Models/MegaMek/Server.swift @@ -0,0 +1,102 @@ +// +// Server.swift +// mul-api +// +// Created by Richard Hancock on 5/9/25. +// + +/// Defines the ``MegaMek/Server`` Fluent model: a record of a live MegaMek game-server +/// instance that has "announced" itself to this API (see `ServersController` and +/// `Api.V1.ServersController`), so the announcement can be listed publicly (e.g. an +/// in-game server browser). + +import Fluent +import Vapor + +extension MegaMek { + /// A Fluent database model representing one announced MegaMek server instance: + /// its network address, whether it's password-protected, connected users, and version. + /// + /// `@Field` maps a required stored column; `@OptionalField` maps a nullable one; + /// `@Timestamp` auto-manages a date column on create/update. `Content` lets this model + /// be encoded/decoded directly as JSON by Vapor for the read-only listing endpoints. + final class Server: Model, Content, @unchecked Sendable { + public static let schema: String = MegaMek.Server.V20250509.schemaName + public static let space: String? = MegaMek.Server.V20250509.spaceName + + @ID + var id: UUID? + + // Network location the server is reachable at, and whether it requires a password. + @Field(key: MegaMek.Server.V20250509.port) + var port: Int + + @Field(key: MegaMek.Server.V20250509.ipAddress) + var ipAddress: String + + @Field(key: MegaMek.Server.V20250509.passworded) + var passworded: Bool + + // Connected users (stored as a single comma-separated string, not a JSON array), + // and a secret key the announcing client can present later to update this same + // record instead of creating a duplicate. + @Field(key: MegaMek.Server.V20250509.users) + var users: String + + @Field(key: MegaMek.Server.V20250509.serverKey) + var serverKey: String + + // Game/server version, current game phase, and an optional message-of-the-day. + @Field(key: MegaMek.Server.V20250509.version) + var version: String + + @Field(key: MegaMek.Server.V20250509.phase) + var phase: String + + @OptionalField(key: MegaMek.Server.V20250509.motd) + var motd: String? + + @Timestamp(key: MegaMek.Server.V20250509.createdAt, on: .create) + var createdAt: Date? + + @Timestamp(key: MegaMek.Server.V20250509.updatedAt, on: .update) + var updatedAt: Date? + + /// Empty initializer required by Fluent so it can hydrate instances from the database. + init() {} + + /// Creates a new server announcement record and generates a fresh random + /// `serverKey` (a 16-byte, base64url-encoded secret) that the client can use on + /// subsequent announcements to update this same record rather than create a new one. + /// - Parameters: + /// - id: Optional pre-assigned identifier; normally left `nil` and generated by the database. + /// - port: The port the game server is listening on; defaults to MegaMek's standard `2346`. + /// - ipAddress: The IP address the server is reachable at. + /// - passworded: Whether the server requires a password to join; defaults to `false`. + /// - users: Comma-separated list of connected usernames; defaults to empty. + /// - version: The MegaMek version string the server is running. + /// - phase: The current game phase. + /// - motd: Optional message-of-the-day text. + init( + id: UUID? = nil, + port: Int = 2346, + ipAddress: String, + passworded: Bool = false, + users: String = "", + version: String, + phase: String, + motd: String? = nil + ) { + self.id = id + self.port = port + self.ipAddress = ipAddress + self.passworded = passworded + self.users = users + self.version = version + self.phase = phase + self.motd = motd + + self.serverKey = [UInt8].random(count: 16).base64.base64URLSafe() + } + } +} diff --git a/Sources/App/Models/Security.swift b/Sources/App/Models/Security.swift index 8a1ae3c..e55b457 100644 --- a/Sources/App/Models/Security.swift +++ b/Sources/App/Models/Security.swift @@ -5,6 +5,9 @@ // Created by Richard Hancock on 3/16/23. // -import Vapor - +/// Empty namespacing type for the app's authentication/authorization domain. +/// +/// It has no members itself — its only purpose is to let related types be declared as +/// extensions on it (e.g. ``Security/User``, ``Security/Token``, ``Security/UserRole``) so +/// they are grouped under a common `Security.` prefix instead of polluting the global namespace. struct Security {} diff --git a/Sources/App/Models/Security/ConfirmationToken+DTOs.swift b/Sources/App/Models/Security/ConfirmationToken+DTOs.swift index 2d6f9c1..b523d8a 100644 --- a/Sources/App/Models/Security/ConfirmationToken+DTOs.swift +++ b/Sources/App/Models/Security/ConfirmationToken+DTOs.swift @@ -6,8 +6,16 @@ // Created by Richard Hancock on 4/2/23. // +/// DTO (Data Transfer Object) for the email-confirmation request. +/// +/// A DTO describes the exact JSON shape a client sends/receives for one endpoint, kept +/// separate from the Fluent `Model` so the wire format can differ from (and never +/// accidentally leak internal fields of) the database record. + import Vapor +/// Payload submitted by a client to confirm an email address; carries just the token value +/// from the confirmation link/email, which is looked up against ``Security/ConfirmationToken``. struct ConfirmUserDTO: Codable, Content { var confirmToken: String } diff --git a/Sources/App/Models/Security/ConfirmationToken.swift b/Sources/App/Models/Security/ConfirmationToken.swift index cdf059b..790dea0 100644 --- a/Sources/App/Models/Security/ConfirmationToken.swift +++ b/Sources/App/Models/Security/ConfirmationToken.swift @@ -6,10 +6,22 @@ // Created by Richard Hancock on 3/16/23. // +/// Defines the ``Security/ConfirmationToken`` Fluent model, used to verify a user's email +/// address (initial signup, or a subsequent email-address change) via a one-time link. +/// +/// Also provides the `generate(for:)` factory and an `isValid` expiry check. + import Fluent import Vapor extension Security { + /// A Fluent database model (an ORM record backed by a table) representing a single-use + /// email confirmation token issued to a ``Security/User``. + /// + /// `@Field`/`@Parent`/`@Timestamp` are Fluent property wrappers: `@Field` maps a stored + /// column, `@Parent` declares a belongs-to relationship (here, the user the token was + /// issued for), and `@Timestamp` auto-manages a date column (here, set once on creation). + /// `Content` lets this model be encoded/decoded directly as JSON by Vapor. final class ConfirmationToken: Model, Content, @unchecked Sendable { public static let schema: String = Security.ConfirmationToken.V20230316.schemaName public static let space: String? = Security.ConfirmationToken.V20230316.spaceName @@ -17,6 +29,7 @@ extension Security { @ID var id: UUID? + // The random token value sent to the user, and the user it was issued for. @Field(key: Security.ConfirmationToken.V20230316.value) var value: String @@ -26,8 +39,15 @@ extension Security { @Timestamp(key: Security.User.V20221227.createdAt, on: .create) var createdAt: Date? + /// Empty initializer required by Fluent so it can hydrate instances from the database. init() {} + /// Creates a token record in memory (not yet persisted) with an explicit value and + /// owning user ID. + /// - Parameters: + /// - id: Optional pre-assigned identifier; normally left `nil` and generated by the database. + /// - value: The random token string that will be sent to the user. + /// - userID: The identifier of the ``Security/User`` this token confirms. init(id: UUID? = nil, value: String, userID: User.IDValue) { self.id = id self.value = value @@ -37,11 +57,18 @@ extension Security { } extension Security.ConfirmationToken { + /// Generates a fresh confirmation token for the given user: a cryptographically random + /// 32-byte value, base64url-encoded, that is not yet saved to the database. + /// - Parameter user: The ``Security/User`` the token will confirm; must already have an ID. + /// - Returns: A new, unsaved ``Security/ConfirmationToken``. + /// - Throws: If `user` has no assigned ID yet. static func generate(for user: Security.User) throws -> Security.ConfirmationToken { let random = [UInt8].random(count: 32).base64.base64URLSafe() return try Security.ConfirmationToken(value: random, userID: user.requireID()) } + /// Whether this token is still usable. Confirmation tokens expire 72 hours after + /// creation; past that window this returns `false` and the token should be rejected. var isValid: Bool { if let date = Calendar.current.date(byAdding: .hour, value: -72, to: Date()), let createdDate = self.createdAt, diff --git a/Sources/App/Models/Security/ForgotPassword+DTOs.swift b/Sources/App/Models/Security/ForgotPassword+DTOs.swift index 1cb5a51..bec7a44 100644 --- a/Sources/App/Models/Security/ForgotPassword+DTOs.swift +++ b/Sources/App/Models/Security/ForgotPassword+DTOs.swift @@ -6,19 +6,33 @@ // Created by Richard Hancock on 4/2/23. // +/// DTOs (Data Transfer Objects) for the forgot-password flow: requesting a reset email, +/// the template context used to render that email, and submitting a new password. +/// +/// DTOs describe the exact JSON shape a client sends/receives for one endpoint, kept +/// separate from the Fluent `Model` so the wire format can differ from (and never +/// accidentally leak internal fields, like the password hash, from) the database record. + import Vapor +/// Payload submitted by a client to request a password-reset email. struct ForgotPasswordDTO: Codable, Content { + // The address to email, and the base URL the reset link should point back to (e.g. a + // front-end app's domain, since this API doesn't know where its clients are hosted). var email: String var url: String } +/// Template-rendering context passed to the email system when sending a password-reset +/// email; not sent over the API, just used internally to fill in the email body. struct ForgotPasswordContext: Encodable { var resetToken: String var remoteURL: String var user: Security.User } +/// Payload submitted by a client to complete a password reset: the reset token from the +/// emailed link plus the new password (entered twice for confirmation). struct ForgotPasswordResetDTO: Codable, Content { var resetToken: String var password: String diff --git a/Sources/App/Models/Security/ForgotPasswordToken.swift b/Sources/App/Models/Security/ForgotPasswordToken.swift index d978b31..0e3f30f 100644 --- a/Sources/App/Models/Security/ForgotPasswordToken.swift +++ b/Sources/App/Models/Security/ForgotPasswordToken.swift @@ -6,10 +6,22 @@ // Created by Richard Hancock on 3/12/23. // +/// Defines the ``Security/ForgotPasswordToken`` Fluent model, used to authorize a +/// password-reset request via a one-time link emailed to the user. +/// +/// Also provides the `generate(for:)` factory and an `isValid` expiry check. + import Fluent import Vapor extension Security { + /// A Fluent database model representing a single-use password-reset token issued to a + /// ``Security/User``. + /// + /// `@Field`/`@Parent`/`@Timestamp` are Fluent property wrappers: `@Field` maps a stored + /// column, `@Parent` declares a belongs-to relationship (here, the user requesting the + /// reset), and `@Timestamp` auto-manages a date column (here, set once on creation). + /// `Content` lets this model be encoded/decoded directly as JSON by Vapor. final class ForgotPasswordToken: Model, Content, @unchecked Sendable { public static let schema: String = Security.ForgotPasswordToken.V20230312.schemaName public static let space: String? = Security.ForgotPasswordToken.V20230312.spaceName @@ -17,6 +29,7 @@ extension Security { @ID var id: UUID? + // The random token value sent to the user, and the user requesting the reset. @Field(key: Security.ForgotPasswordToken.V20230312.value) var value: String @@ -26,8 +39,15 @@ extension Security { @Timestamp(key: Security.User.V20221227.createdAt, on: .create) var createdAt: Date? + /// Empty initializer required by Fluent so it can hydrate instances from the database. init() {} + /// Creates a token record in memory (not yet persisted) with an explicit value and + /// owning user ID. + /// - Parameters: + /// - id: Optional pre-assigned identifier; normally left `nil` and generated by the database. + /// - value: The random token string that will be sent to the user. + /// - userID: The identifier of the ``Security/User`` requesting the reset. init(id: UUID? = nil, value: String, userID: User.IDValue) { self.id = id self.value = value @@ -37,11 +57,20 @@ extension Security { } extension Security.ForgotPasswordToken { + /// Generates a fresh password-reset token for the given user: a cryptographically + /// random 24-byte value, base64url-encoded, that is not yet saved to the database. + /// - Parameter user: The ``Security/User`` requesting the reset; must already have an ID. + /// - Returns: A new, unsaved ``Security/ForgotPasswordToken``. + /// - Throws: If `user` has no assigned ID yet. static func generate(for user: Security.User) throws -> Security.ForgotPasswordToken { let random = [UInt8].random(count: 24).base64.base64URLSafe() return try Security.ForgotPasswordToken(value: random, userID: user.requireID()) } + /// Whether this token is still usable. Password-reset tokens expire 1 hour after + /// creation (much shorter than confirmation tokens, since a leaked reset link is more + /// immediately dangerous); past that window this returns `false` and the token should + /// be rejected. var isValid: Bool { if let date = Calendar.current.date(byAdding: .hour, value: -1, to: Date()), let createdDate = self.createdAt, diff --git a/Sources/App/Models/Security/Role.swift b/Sources/App/Models/Security/Role.swift index 5a329bc..b75bd51 100644 --- a/Sources/App/Models/Security/Role.swift +++ b/Sources/App/Models/Security/Role.swift @@ -6,9 +6,15 @@ // Created by Richard Hancock on 12/27/22. // +/// Defines ``Security/UserRole``, the permission-level enum stored on every ``Security/User``. + import Vapor extension Security { + /// The permission/status level of a ``Security/User``, roughly ordered from least to + /// most privileged, plus special statuses (`deactivated`, `nonConfirmed`, `banned`) that + /// mark an account as not currently active. Conforms to `Content` so it can be + /// encoded/decoded directly as JSON, and `CaseIterable` for enumerating valid roles. enum UserRole: String, Content, CaseIterable { case guest case registered @@ -24,6 +30,10 @@ extension Security { case nonConfirmed = "non_confirmed" case banned + /// Manually overrides the compiler-synthesized `CaseIterable.allCases` to control + /// ordering and to deliberately omit `.superAdministrator` (that role is not meant + /// to be assignable/listed through normal role-management UI or API surfaces). Holds + /// the roles considered selectable/listable, in display order. static var allCases: [Security.UserRole] { [ .banned, diff --git a/Sources/App/Models/Security/Token.swift b/Sources/App/Models/Security/Token.swift index eba2680..8395799 100644 --- a/Sources/App/Models/Security/Token.swift +++ b/Sources/App/Models/Security/Token.swift @@ -6,10 +6,21 @@ // Created by Richard Hancock on 1/19/23. // +/// Defines the ``Security/Token`` Fluent model: the bearer authentication token issued to a +/// ``Security/User`` after login and presented on subsequent requests (e.g. as an +/// `Authorization: Bearer ` header) instead of resending credentials. + import Fluent import Vapor extension Security { + /// A Fluent database model representing a session/API bearer token belonging to a + /// ``Security/User``. + /// + /// `@Field`/`@Parent`/`@Timestamp` are Fluent property wrappers: `@Field` maps a stored + /// column, `@Parent` declares a belongs-to relationship (here, the authenticated user), + /// and `@Timestamp` auto-manages a date column (here, set once on creation). `Content` + /// lets this model be encoded/decoded directly as JSON by Vapor. final class Token: Model, Content, @unchecked Sendable { public static let schema: String = Security.Token.V20230119.schemaName public static let space: String? = Security.Token.V20230119.spaceName @@ -17,6 +28,7 @@ extension Security { @ID var id: UUID? + // The token's secret value, and the user it authenticates as. @Field(key: Security.Token.V20230119.value) var value: String @@ -26,8 +38,15 @@ extension Security { @Timestamp(key: Security.User.V20221227.createdAt, on: .create) var createdAt: Date? + /// Empty initializer required by Fluent so it can hydrate instances from the database. init() {} + /// Creates a token record in memory (not yet persisted) with an explicit value and + /// owning user ID. + /// - Parameters: + /// - id: Optional pre-assigned identifier; normally left `nil` and generated by the database. + /// - value: The secret bearer-token string. + /// - userID: The identifier of the ``Security/User`` this token authenticates as. init(id: UUID? = nil, value: String, userID: User.IDValue) { self.id = id self.value = value @@ -37,23 +56,38 @@ extension Security { } extension Security.Token { + /// Generates a fresh bearer token for the given user: a cryptographically random + /// 16-byte value, base64url-encoded, that is not yet saved to the database. Typically + /// called right after a successful login to hand back to the client. + /// - Parameter user: The ``Security/User`` to issue the token for; must already have an ID. + /// - Returns: A new, unsaved ``Security/Token``. + /// - Throws: If `user` has no assigned ID yet. static func generate(for user: User) throws -> Security.Token { let random = [UInt8].random(count: 16).base64.base64URLSafe() return try Security.Token(value: random, userID: user.requireID()) } } +/// Conformance to Vapor's `ModelTokenAuthenticatable`, which wires this model into Vapor's +/// bearer-token authentication middleware: given a token value from an incoming request, +/// Vapor looks up the matching `Security.Token` row and, if `isValid`, authenticates the +/// associated ``Security/User`` for that request. extension Security.Token: ModelTokenAuthenticatable { + /// Key path telling Vapor which field holds the raw token value to match against. static var valueKey: KeyPath> { \Security.Token.$value } + /// Key path telling Vapor which relationship holds the authenticated user. static var userKey: KeyPath> { \Security.Token.$user } typealias User = App.Security.User + /// Whether this token is still usable. Tokens expire 14 days after creation; past that + /// window this returns `false` and Vapor's authentication middleware will reject it, + /// requiring the user to log in again. var isValid: Bool { if let date = Calendar.current.date(byAdding: .day, value: -14, to: Date()), let createdDate = self.createdAt, diff --git a/Sources/App/Models/Security/User+Confirmable.swift b/Sources/App/Models/Security/User+Confirmable.swift index f792c88..9373bf3 100644 --- a/Sources/App/Models/Security/User+Confirmable.swift +++ b/Sources/App/Models/Security/User+Confirmable.swift @@ -6,11 +6,23 @@ // Created by Richard Hancock on 4/2/23. // +/// Email-confirmation workflow methods on ``Security/User``: generating and sending +/// confirmation-link emails (for signup, and for both the old and new address when a user +/// changes their email), plus marking a user confirmed once they click the link. + import Fluent +import SendGrid import Vapor extension Security.User { + /// Generates a ``Security/ConfirmationToken``, saves it, and emails the user a link to + /// confirm their account after initial signup. + /// - Parameters: + /// - req: The current request, used for database access and sending the email. + /// - remoteURL: Base URL of the front-end app, used to build the link in the email. + /// - Throws: If token generation, saving, or sending the email fails. func sendConfirmEmail(_ req: Request, remoteURL: String) async throws { + let token = try Security.ConfirmationToken.generate(for: self) try await token.save(on: req.db) @@ -20,15 +32,21 @@ extension Security.User { user: self ) - /* - _ = try await EmailHandler.sendEmail( - self, - subject: "Confirm Email", - body: "emails/confirmAccount", - context: context, - on: req) */ + + _ = try await EmailHandler.sendEmail( + self, + subject: "Confirm Email", + body: "emails/confirmAccount", + context: context, + on: req) } + /// Generates a ``Security/ConfirmationToken`` and emails the user's *current* address + /// to notify/confirm that an email-change request was made. + /// - Parameters: + /// - req: The current request, used for database access and sending the email. + /// - remoteURL: Base URL of the front-end app, used to build the link in the email. + /// - Throws: If token generation, saving, or sending the email fails. func sendConfirmEmailChange(_ req: Request, remoteURL: String) async throws { let token = try Security.ConfirmationToken.generate(for: self) try await token.save(on: req.db) @@ -38,15 +56,21 @@ extension Security.User { remoteURL: remoteURL, user: self ) - /* - _ = try await EmailHandler.sendEmail( - self, - subject: "Confirm Email Change Request", - body: "emails/updateEmail", - context: context, - on: req) */ + + _ = try await EmailHandler.sendEmail( + self, + subject: "Confirm Email Change Request", + body: "emails/updateEmail", + context: context, + on: req) } + /// Generates a ``Security/ConfirmationToken`` and emails the user's *new* (pending, + /// unconfirmed) address so they can confirm ownership of it before it replaces `email`. + /// - Parameters: + /// - req: The current request, used for database access and sending the email. + /// - remoteURL: Base URL of the front-end app, used to build the link in the email. + /// - Throws: If token generation, saving, or sending the email fails. func sendNewEmailChange(_ req: Request, remoteURL: String) async throws { let token = try Security.ConfirmationToken.generate(for: self) try await token.save(on: req.db) @@ -56,15 +80,20 @@ extension Security.User { remoteURL: remoteURL, user: self ) - /* - _ = try await EmailHandler.sendEmailChange( - self, - subject: "Confirm New Email", - body: "emails/confirmAccount", - context: context, - on: req) */ + + _ = try await EmailHandler.sendEmailChange( + self, + subject: "Confirm New Email", + body: "emails/confirmAccount", + context: context, + on: req) } + /// Marks this user as confirmed: stamps `confirmedAt` with the current date, promotes + /// the role from `.nonConfirmed` to `.registered`, and persists the change. Called once + /// a submitted confirmation token has been validated. + /// - Parameter db: The database connection to save on. + /// - Throws: If saving the updated user fails. func confirm(on db: any Database) async throws { self.confirmedAt = Date() self.role = .registered @@ -72,6 +101,9 @@ extension Security.User { } } +/// Template-rendering context passed to the email system when sending any of the +/// confirmation emails above; not sent over the API, just used internally to fill in the +/// email body. struct ConfirmUserContext: Encodable { var confirmToken: String var remoteURL: String diff --git a/Sources/App/Models/Security/User+Helpers.swift b/Sources/App/Models/Security/User+Helpers.swift index 74d3d2b..23e04cf 100644 --- a/Sources/App/Models/Security/User+Helpers.swift +++ b/Sources/App/Models/Security/User+Helpers.swift @@ -1,10 +1,15 @@ // -// File.swift +// User+Helpers.swift // // // Created by Richard Hancock on 4/2/23. // +/// Role/permission-checking helpers and a uniqueness guard on ``Security/User``. These +/// convenience methods translate the raw ``Security/UserRole`` into the yes/no questions +/// call sites actually need (is this account active, does it have at least this privilege +/// level, etc.) instead of switching on the role everywhere. + import Fluent import Vapor @@ -21,6 +26,9 @@ import Vapor /// case superAdministrator = "super_administrator" extension Security.User { + /// Whether the account is usable for authentication — `false` for banned, deactivated, + /// or not-yet-confirmed accounts, `true` for every other role. + /// - Returns: `true` if the user is allowed to log in / act. func active() -> Bool { switch self.role { case .banned, .deactivated, .nonConfirmed: @@ -30,6 +38,10 @@ extension Security.User { } } + /// Checks that no other user already has this user's `username` or `email`, throwing a + /// conflict error if one does. Intended to be called before creating/updating a user. + /// - Parameter req: The current request, used for database access. + /// - Throws: `Abort(.conflict)` if the username or email is already taken. func ensureUnique(_ req: Request) async throws { guard try await Security.User.query(on: req.db) @@ -45,6 +57,8 @@ extension Security.User { } } + /// Whether the role grants at least subscriber-level privileges (subscriber and above). + /// - Returns: `true` if the role is `.subscriber` or higher. func isSubscriber() -> Bool { switch self.role { case .subscriber, .demoAgent, .contentManagement, .developer, .administrator, @@ -55,6 +69,8 @@ extension Security.User { } } + /// Whether the role grants at least demo-agent-level privileges (demo agent and above). + /// - Returns: `true` if the role is `.demoAgent` or higher. func isDemoAgent() -> Bool { switch self.role { case .demoAgent, .contentManagement, .developer, .administrator, .superAdministrator: @@ -64,6 +80,8 @@ extension Security.User { } } + /// Whether the role grants at least content-management-level privileges. + /// - Returns: `true` if the role is `.contentManagement` or higher. func isContentManagement() -> Bool { switch self.role { case .contentManagement, .developer, .administrator, .superAdministrator: @@ -73,6 +91,8 @@ extension Security.User { } } + /// Whether the role grants at least developer-level privileges. + /// - Returns: `true` if the role is `.developer` or higher. func isDeveloper() -> Bool { switch self.role { case .developer, .administrator, .superAdministrator: @@ -82,6 +102,8 @@ extension Security.User { } } + /// Whether the role grants administrator-level privileges. + /// - Returns: `true` if the role is `.administrator` or `.superAdministrator`. func isAdministrator() -> Bool { switch self.role { case .administrator, .superAdministrator: diff --git a/Sources/App/Models/Security/User.swift b/Sources/App/Models/Security/User.swift index 03e8d0a..442b03a 100644 --- a/Sources/App/Models/Security/User.swift +++ b/Sources/App/Models/Security/User.swift @@ -6,10 +6,18 @@ // Created by Richard Hancock on 12/27/22. // +/// Defines the ``Security/User`` Fluent model (the core account/authentication record), +/// its error type, and two public-facing views of a user (``Security/User/Profile`` and +/// ``Security/User/Public``) used to avoid ever serializing sensitive fields like the +/// password hash directly over the API. + import Fluent import Vapor extension Security { + /// Errors surfaced by user account operations (password changes, role changes, + /// activation state, email confirmation), each with a human-readable description + /// suitable for returning in an API error response. enum UserError: Error, LocalizedError { case passwordsDoNotMatch case cantModifyAdminAsAnAdmin @@ -33,6 +41,15 @@ extension Security { } } + /// A Fluent database model representing a registered account: credentials, contact + /// info, role, and confirmation/audit timestamps. + /// + /// `@Field` maps a required stored column; `@OptionalField` maps a nullable one; + /// `@Timestamp` auto-manages a date column on a lifecycle event (`.create`, `.update`, + /// or `.delete`, the last enabling Fluent's soft-delete behavior). `Content` lets this + /// model be encoded/decoded directly as JSON by Vapor — note that doing so naively + /// would expose the `password` hash, which is why ``Security/User/Public`` and + /// ``Security/User/Profile`` exist as safer response shapes (see below). final class User: Model, Content, @unchecked Sendable { public static let schema: String = Security.User.V20221227.schemaName public static let space: String? = Security.User.V20221227.spaceName @@ -40,6 +57,7 @@ extension Security { @ID var id: UUID? + // Optional given/family name split out separately from the combined display `name`. @OptionalField(key: Security.User.V20230818.firstName) var firstName: String? @@ -52,18 +70,23 @@ extension Security { @Field(key: Security.User.V20221227.username) var username: String + // The Bcrypt-hashed password (never the plaintext) and the confirmed login email. @Field(key: Security.User.V20221227.password) var password: String @Field(key: Security.User.V20221227.email) var email: String + // Holds a pending new email address until the user confirms it via a + // ConfirmationToken; `email` itself is only updated once confirmed. @OptionalField(key: Security.User.V20230818A.unconfirmedEmail) var unconfirmedEmail: String? @Field(key: Security.User.V20221227.role) var role: UserRole + // When the account's email was confirmed, and Fluent-managed lifecycle timestamps + // (createdAt/updatedAt set automatically; deletedAt enables Fluent soft-deletes). @OptionalField(key: Security.User.V20230316.confirmedAt) var confirmedAt: Date? @@ -76,8 +99,17 @@ extension Security { @Timestamp(key: Security.User.V20221227.deletedAt, on: .delete) var deletedAt: Date? + /// Empty initializer required by Fluent so it can hydrate instances from the database. init() {} + /// Creates a new user with a single combined display name (no separate first/last). + /// - Parameters: + /// - id: Optional pre-assigned identifier; normally left `nil` and generated by the database. + /// - name: The display name. + /// - username: The unique login username. + /// - email: The account's email address. + /// - role: Initial ``Security/UserRole``; defaults to `.nonConfirmed` since new accounts + /// start unverified. init( id: UUID? = nil, name: String, @@ -91,6 +123,16 @@ extension Security { self.role = role } + /// Creates a new user from separate first/last names, deriving the combined `name` + /// as `"\(firstName) \(lastName)"`. + /// - Parameters: + /// - id: Optional pre-assigned identifier; normally left `nil` and generated by the database. + /// - firstName: Given name. + /// - lastName: Family name. + /// - username: The unique login username. + /// - email: The account's email address. + /// - role: Initial ``Security/UserRole``; defaults to `.nonConfirmed` since new accounts + /// start unverified. init( id: UUID? = nil, firstName: String, @@ -111,6 +153,8 @@ extension Security { } extension Security.User { + /// DTO representing a user's own detailed profile (e.g. for an "account settings" page): + /// everything a user is allowed to see about themselves, but never the password hash. final class Profile: Content { let id: UUID let name: String @@ -121,6 +165,16 @@ extension Security.User { let role: Security.UserRole let createdAt: Date + /// Creates a profile view from already-extracted field values. + /// - Parameters: + /// - id: The user's identifier. + /// - name: Combined display name. + /// - firstName: Given name (empty string if not set). + /// - lastName: Family name (empty string if not set). + /// - username: Login username. + /// - email: Email address. + /// - role: Current ``Security/UserRole``. + /// - createdAt: When the account was created. init( id: UUID, name: String, @@ -142,6 +196,8 @@ extension Security.User { } } + /// Converts this database model into the safe-to-serialize ``Security/User/Profile`` DTO. + /// - Returns: A ``Security/User/Profile`` copying this user's public/self-visible fields. func convertToProfile() -> Security.User.Profile { Security.User.Profile( id: id!, @@ -157,12 +213,20 @@ extension Security.User { } extension Security.User { + /// DTO representing the minimal, safe-to-share view of a user shown to other users + /// (e.g. in author bylines or user lists) — no email, no password hash. final class Public: Content { let id: UUID let name: String let username: String let role: Security.UserRole + /// Creates a public view from already-extracted field values. + /// - Parameters: + /// - id: The user's identifier. + /// - name: Combined display name. + /// - username: Login username. + /// - role: Current ``Security/UserRole``. init(id: UUID, name: String, username: String, role: Security.UserRole) { self.id = id self.name = name @@ -171,12 +235,20 @@ extension Security.User { } } + /// Converts this database model into the safe-to-serialize ``Security/User/Public`` DTO. + /// - Returns: A ``Security/User/Public`` copying this user's publicly visible fields. func convertToPublic() -> Security.User.Public { Security.User.Public(id: id!, name: name, username: username, role: role) } } extension Security.User { + /// Sets a new password after checking it was entered twice identically, hashing it with + /// Bcrypt before storing (the plaintext password is never persisted). + /// - Parameters: + /// - password: The new plaintext password. + /// - passwordConfirmation: A repeat of the new password, must match `password`. + /// - Throws: ``Security/UserError/passwordsDoNotMatch`` if the two values differ. func setPassword(_ password: String, confirm passwordConfirmation: String) throws { if password != passwordConfirmation { throw Abort( @@ -186,6 +258,10 @@ extension Security.User { self.password = try Bcrypt.hash(password) } + /// Updates the user's first/last name and re-derives the combined display `name`. + /// - Parameters: + /// - firstName: New given name. + /// - lastName: New family name. func updateName(firstName: String, lastName: String) { self.firstName = firstName self.lastName = lastName @@ -193,15 +269,27 @@ extension Security.User { } } +/// Conformance to Vapor's `ModelAuthenticatable`, which wires this model into Vapor's +/// username/password (Basic Auth) authentication middleware: given a username and password +/// from an incoming request, Vapor looks up the matching `Security.User` row and calls +/// `verify(password:)` below to check the credentials. extension Security.User: ModelAuthenticatable { + /// Key path telling Vapor which field holds the login username. static var usernameKey: KeyPath> { \Security.User.$username } + /// Key path telling Vapor which field holds the Bcrypt password hash. static var passwordHashKey: KeyPath> { \Security.User.$password } + /// Checks a plaintext password against this user's stored Bcrypt hash, but only for + /// active accounts — banned, deactivated, or unconfirmed users are rejected outright + /// regardless of whether the password is correct. + /// - Parameter password: The plaintext password to verify. + /// - Returns: `true` if the password matches the stored hash. + /// - Throws: ``Security/UserError/userNotActive`` if `active()` is `false`. func verify(password: String) throws -> Bool { guard self.active() else { throw Abort(.unauthorized, reason: Security.UserError.userNotActive.errorDescription) @@ -210,11 +298,17 @@ extension Security.User: ModelAuthenticatable { } } +/// Convenience conversions for collections of users, so a whole query result can be mapped +/// to its DTO form in one call instead of a manual `.map`. extension Collection where Element: Security.User { + /// Converts every element to its ``Security/User/Public`` DTO. + /// - Returns: The public views, in the same order. func convertToPublic() -> [Security.User.Public] { return self.map { $0.convertToPublic() } } + /// Converts every element to its ``Security/User/Profile`` DTO. + /// - Returns: The profile views, in the same order. func convertToProfile() -> [Security.User.Profile] { return self.map { $0.convertToProfile() } } diff --git a/Sources/App/Settings/APIMigrations.swift b/Sources/App/Settings/APIMigrations.swift index ee72c1d..bb3acec 100644 --- a/Sources/App/Settings/APIMigrations.swift +++ b/Sources/App/Settings/APIMigrations.swift @@ -1,5 +1,15 @@ -// -// Migrations.swift +/// Migrations.swift +/// +/// The definitive, ordered list of every Fluent migration applied to this app's database +/// at startup — the "table of contents" for how the schema has evolved over time and in +/// what order. Each `app.migrations.add(...)` call below registers one migration type +/// (see the individual files, e.g. under `Migrations/Security/` and `Migrations/MegaMek/`, +/// each named with a date prefix such as `2022-12-27-CreateUser.swift`). Vapor/Fluent runs +/// registered migrations in this list's order and records which have already run, so: +/// - New migrations must be appended, never inserted earlier in the list. +/// - The order here should track the chronological order of the migrations' date +/// prefixes, since a later migration may depend on a table/column an earlier one +/// created. // // All Migrations for the app. // @@ -10,7 +20,11 @@ import Fluent import QueuesFluentDriver import Vapor +/// Namespace holding the app's migration-registration logic. struct APIMigrations { + /// Registers every migration this app knows about with Vapor, in the order they must + /// run to reproduce the current schema from scratch. + /// - Parameter app: The Vapor `Application` whose `app.migrations` registry is populated. static func applyMigrations(_ app: Application) { // Migrations @@ -50,5 +64,10 @@ struct APIMigrations { // Queues app.migrations.add(JobModelMigration()) + // MegaMek + app.migrations.add(MegaMek.CreateServer()) + + // Fixes + app.migrations.add(BattleTech.FixAmmoAeroUseColumnName()) } } diff --git a/Sources/App/Settings/Database.swift b/Sources/App/Settings/Database.swift index a273124..4546e84 100644 --- a/Sources/App/Settings/Database.swift +++ b/Sources/App/Settings/Database.swift @@ -12,7 +12,16 @@ import Fluent import FluentPostgresDriver import Vapor +/// Namespace holding the primary/replica database configuration for the app. +/// Invoked once from ``configure(_:)`` during startup, before migrations run. struct ConfigureDatabase { + /// Registers the Postgres database connection (reading host/port/credentials + /// from environment variables, with local-dev defaults) and enables the + /// Fluent-backed Queues driver, which stores background job state in the + /// same database. + /// + /// - Parameter app: The `Application` to register the database on. + /// - Throws: Rethrows errors from building the TLS client configuration. static func configure(_ app: Application) async throws { let defaultPort = SQLPostgresConfiguration.ianaPortNumber diff --git a/Sources/App/Settings/JobSchedules.swift b/Sources/App/Settings/JobSchedules.swift index 87eeee4..3e0e63a 100644 --- a/Sources/App/Settings/JobSchedules.swift +++ b/Sources/App/Settings/JobSchedules.swift @@ -8,13 +8,33 @@ import QueuesFluentDriver import Vapor +/// Wires up Vapor's Queues package, which lets the app hand off slow work (like +/// importing thousands of CSV/XML rows) to background workers instead of blocking +/// an HTTP request. Jobs are dispatched with `req.queue.dispatch(_:_:)`, persisted +/// to the database via the Fluent queues driver, and later "dequeued" (executed) by +/// an in-process worker loop. Both functions here are called once from +/// ``configure(_:)`` during startup. struct JobSchedules { + /// Configures the queue driver and worker settings: uses the Fluent-backed + /// queue storage (jobs persisted in the app's database), a single worker, + /// and a 5-second polling interval for picking up newly dispatched jobs. + /// + /// - Parameter app: The `Application` to configure queues on. + /// - Throws: Does not currently throw, but matches the async-throwing + /// configuration hooks used elsewhere in startup. static func setupQueues(_ app: Application) async throws { app.queues.use(.fluent()) app.queues.configuration.workerCount = 1 app.queues.configuration.refreshInterval = .seconds(5) } + /// Registers every `Job` type the app knows how to run and starts the + /// in-process worker and scheduler (skipped in the `.testing` environment so + /// test runs don't spin up background workers). + /// + /// - Parameter app: The `Application` whose queues should be started. + /// - Throws: Rethrows errors from starting the in-process job or scheduled-job + /// loops. static func scheduleJobs(_ app: Application) async throws { // Add Queuable Jobs app.queues.add(WeaponImportJob()) diff --git a/Sources/App/configure.swift b/Sources/App/configure.swift index 90b0892..85e12e2 100644 --- a/Sources/App/configure.swift +++ b/Sources/App/configure.swift @@ -1,3 +1,6 @@ +/// Application bootstrap: wires up the database, background job queues, view +/// rendering, and HTTP routes. Called once from ``Entrypoint/main()`` before the +/// server starts accepting requests. import Fluent import FluentPostgresDriver import Leaf @@ -5,7 +8,15 @@ import NIOSSL import QueuesFluentDriver import Vapor -// configures your application +/// Configures the Vapor `Application` for this service: sets request-body limits and +/// static file serving, connects to Postgres and applies Fluent migrations (via +/// ``ConfigureDatabase`` and `APIMigrations`), sets up and schedules the Queues-based +/// background jobs (via ``JobSchedules``), enables the Leaf templating engine (used to +/// render outgoing email bodies), and finally registers all HTTP routes. +/// +/// - Parameter app: The `Application` instance to configure in place. +/// - Throws: Rethrows errors from database configuration, migrations, queue setup, or +/// route registration. public func configure(_ app: Application) async throws { app.routes.defaultMaxBodySize = "10mb" app.middleware.use(FileMiddleware(publicDirectory: app.directory.publicDirectory)) diff --git a/Sources/App/entrypoint.swift b/Sources/App/entrypoint.swift index ba237fd..fcb71c9 100644 --- a/Sources/App/entrypoint.swift +++ b/Sources/App/entrypoint.swift @@ -1,14 +1,21 @@ -/* - * Main Entrypoint for the service. - */ +/// Main entrypoint for the service. Boots logging, builds the Vapor +/// `Application`, hands it to ``configure(_:)`` for setup, then runs the server +/// until shutdown. import Logging import NIOCore import NIOPosix import Vapor +/// The `@main` type for the executable; Swift calls ``main()`` to start the app. @main enum Entrypoint { + /// Detects the runtime environment, bootstraps logging, creates the + /// `Application`, runs ``configure(_:)``, then executes the app (serving + /// requests) until it is asked to shut down. + /// + /// - Throws: Any error from environment detection, configuration, or running the + /// application; configuration errors are logged before being rethrown. static func main() async throws { var env = try Environment.detect() try LoggingSystem.bootstrap(from: &env) diff --git a/Sources/App/routes.swift b/Sources/App/routes.swift index 6b503fc..1b353cc 100644 --- a/Sources/App/routes.swift +++ b/Sources/App/routes.swift @@ -1,6 +1,24 @@ +/// Top-level route registration for the app. Called once from `configure(_:)` during +/// startup, after the database, migrations, and job queues are set up. Mounts each +/// top-level `RouteCollection` (a type that groups related HTTP routes and knows how +/// to register itself on a Vapor `Application`) onto the app's router. import Fluent import Vapor +/// Registers every top-level route group exposed by the API. +/// +/// Mounts: +/// - ``Api/RootController`` for the versioned `/api/v1/...` surface. +/// - ``BattleTech/RootController`` for the public `/battletech/...` data endpoints +/// (weapons, equipment, ammo, factions, eras, rules, tech levels/bases). +/// - ``ServersController`` for the legacy top-level `/servers` endpoints used by +/// MegaMek clients to announce themselves, kept during the transition to the +/// versioned API. +/// +/// - Parameter app: The Vapor `Application` being configured. +/// - Throws: Rethrows any error raised while a controller registers its routes. func routes(_ app: Application) throws { + try app.register(collection: Api.RootController()) try app.register(collection: BattleTech.RootController()) + try app.register(collection: ServersController()) } diff --git a/Tests/AppTests/Application+Testable.swift b/Tests/AppTests/Application+Testable.swift index 9b40322..3679d19 100644 --- a/Tests/AppTests/Application+Testable.swift +++ b/Tests/AppTests/Application+Testable.swift @@ -5,23 +5,25 @@ // Created by Richard Hancock on 10/3/24. // +import Testing +import VaporTesting + @testable import App -@testable import XCTVapor -extension XCTApplicationTester { - public func loginReturningResponse(user: Security.User) throws -> XCTHTTPResponse { - var request = XCTHTTPRequest( +extension TestingApplicationTester { + public func loginReturningResponse(user: Security.User) async throws -> TestingHTTPResponse { + var request = TestingHTTPRequest( method: .POST, url: .init(path: "/authentication"), headers: [:], body: ByteBufferAllocator().buffer(capacity: 0)) request.headers.basicAuthorization = .init(username: user.username, password: "password") - return try performTest(request: request) + return try await performTest(request: request) } - public func login(user: Security.User) throws -> String { - let response = try loginReturningResponse(user: user) + public func login(user: Security.User) async throws -> String { + let response = try await loginReturningResponse(user: user) return try response.content.decode(String.self) } @@ -33,12 +35,14 @@ extension XCTApplicationTester { body: ByteBuffer? = nil, loggedInRequest: Bool = false, loggedInUser: Security.User? = nil, - file: StaticString = #file, - line: UInt = #line, - beforeRequest: (inout XCTHTTPRequest) throws -> Void = { _ in }, - afterResponse: (XCTHTTPResponse) throws -> Void = { _ in } - ) throws -> XCTApplicationTester { - var request = XCTHTTPRequest( + fileID: String = #fileID, + filePath: String = #filePath, + line: Int = #line, + column: Int = #column, + beforeRequest: (inout TestingHTTPRequest) async throws -> Void = { _ in }, + afterResponse: (TestingHTTPResponse) async throws -> Void = { _ in } + ) async throws -> TestingApplicationTester { + var request = TestingHTTPRequest( method: method, url: .init(path: path), headers: headers, @@ -57,20 +61,51 @@ extension XCTApplicationTester { try userToLogin.setPassword("password", confirm: "password") } - let token = try login(user: userToLogin) + let token = try await login(user: userToLogin) request.headers.bearerAuthorization = .init(token: token) } - try beforeRequest(&request) + try await beforeRequest(&request) do { - let response = try performTest(request: request) - try afterResponse(response) + let response = try await performTest(request: request) + try await afterResponse(response) } catch { - XCTFail("\(error)", file: (file), line: line) + let sourceLocation = Testing.SourceLocation( + fileID: fileID, + filePath: filePath, + line: line, + column: column) + Issue.record("\(error)", sourceLocation: sourceLocation) throw error } return self } } + +extension Application { + fileprivate func withMigratedDatabase( + _ body: (Application) async throws -> T + ) async throws -> T { + try await autoMigrate() + do { + let result = try await body(self) + try await autoRevert() + return result + } catch { + try? await autoRevert() + throw error + } + } +} + +/// Creates a configured, migrated `Application` for the duration of `test`, then reverts +/// migrations and shuts the application down, whether or not `test` throws. +public func withTestApp( + _ test: (Application) async throws -> T +) async throws -> T { + try await withApp(configure: configure) { app in + try await app.withMigratedDatabase(test) + } +} diff --git a/Tests/AppTests/Controllers/BattleTech/AmmoControllerTests.swift b/Tests/AppTests/Controllers/BattleTech/AmmoControllerTests.swift index 5f372c4..4afa5ac 100644 --- a/Tests/AppTests/Controllers/BattleTech/AmmoControllerTests.swift +++ b/Tests/AppTests/Controllers/BattleTech/AmmoControllerTests.swift @@ -5,98 +5,95 @@ // import Fluent -import XCTVapor +import Testing +import VaporTesting @testable import App -final class AmmoControllerTests: XCTestCase { - var path = "/battletech/ammo" - var app: Application! - - override func setUp() async throws { - self.app = try await Application.make(.testing) - try await configure(app) - try await app.autoMigrate() - } - - override func tearDown() async throws { - try await app.autoRevert() - try await self.app.asyncShutdown() - self.app = nil - } - - func testIndex() async throws { - _ = try await BattleTech.Ammo.create(on: app.db) - let ammoCount = try await BattleTech.Ammo.query(on: app.db).count() - - try app.test( - .GET, path, - loggedInRequest: false, - afterResponse: { response in - let ammo = try response.content.decode(Page.self) - XCTAssertEqual(ammo.metadata.total, ammoCount) - }) +@Suite(.serialized, .databaseSerialized) +struct AmmoControllerTests { + let path = "/battletech/ammo" + + @Test + func index() async throws { + try await withTestApp { app in + _ = try await BattleTech.Ammo.create(on: app.db) + let ammoCount = try await BattleTech.Ammo.query(on: app.db).count() + + try await app.test( + .GET, path, + loggedInRequest: false, + afterResponse: { response in + let ammo = try response.content.decode(Page.self) + #expect(ammo.metadata.total == ammoCount) + }) + } } - func testShow() async throws { - let ammo = try await BattleTech.Ammo.create(on: app.db) - let showPath = "\(path)/\(ammo.id!)" - - try app.test( - .GET, showPath, - loggedInRequest: false, - afterResponse: { response in - let returnedAmmo = try response.content.decode(BattleTech.Ammo.self) - XCTAssertEqual(ammo.name, returnedAmmo.name) - }) + @Test + func show() async throws { + try await withTestApp { app in + let ammo = try await BattleTech.Ammo.create(on: app.db) + let showPath = "\(path)/\(ammo.id!)" + + try await app.test( + .GET, showPath, + loggedInRequest: false, + afterResponse: { response in + let returnedAmmo = try response.content.decode(BattleTech.Ammo.self) + #expect(ammo.name == returnedAmmo.name) + }) + } } - func testShowNotFound() async throws { - let notFoundPath = "\(path)/NOT-A-UUID" - - try app.test( - .GET, notFoundPath, - loggedInRequest: false, - afterResponse: { response in - XCTAssertEqual(response.status, .notFound) - }) + @Test + func showNotFound() async throws { + try await withTestApp { app in + let notFoundPath = "\(path)/NOT-A-UUID" + + try await app.test( + .GET, notFoundPath, + loggedInRequest: false, + afterResponse: { response in + #expect(response.status == .notFound) + }) + } } - func testDelete() async throws { - let ammo = try await BattleTech.Ammo.create(on: app.db) - let showPath = "\(path)/\(ammo.id!)" - - try app.test( - .DELETE, showPath, - loggedInRequest: false, - afterResponse: { response in - XCTAssertEqual(response.status, .noContent) - }) + @Test + func delete() async throws { + try await withTestApp { app in + let ammo = try await BattleTech.Ammo.create(on: app.db) + let showPath = "\(path)/\(ammo.id!)" + + try await app.test( + .DELETE, showPath, + loggedInRequest: false, + afterResponse: { response in + #expect(response.status == .noContent) + }) + } } - func testImport() async throws { - let (testFileHandle, testFileRegion) = try await app.fileio.openFile( - path: "Tests/Resources/BattleTech/ammo.csv", - eventLoop: app.eventLoopGroup.next() - ).get() - - let testFileByteBuffer = try await app.fileio.read( - fileRegion: testFileRegion, allocator: .init()) - let ammoMassImport = AmmoMassImport( - file: File(data: testFileByteBuffer, filename: "ammo.csv")) - try testFileHandle.close() - - let massImportPath = "\(path)/import" - - try app.test( - .POST, massImportPath, - loggedInRequest: false, - beforeRequest: { request in - try request.content.encode(ammoMassImport) - }, - afterResponse: { response in - XCTAssertEqual(response.status, .created) - XCTAssertNotNil(app.queues.queue.pop()) - }) + @Test + func `import`() async throws { + try await withTestApp { app in + let testFileByteBuffer = try await TestResources.buffer(for: "BattleTech/ammo.csv") + let ammoMassImport = AmmoMassImport( + file: File(data: testFileByteBuffer, filename: "ammo.csv")) + + let massImportPath = "\(path)/import" + + try await app.test( + .POST, massImportPath, + loggedInRequest: false, + beforeRequest: { request in + try request.content.encode(ammoMassImport) + }, + afterResponse: { response in + #expect(response.status == .created) + #expect(app.queues.queue.pop() != nil) + }) + } } } diff --git a/Tests/AppTests/Controllers/BattleTech/EquipmentControllerTests.swift b/Tests/AppTests/Controllers/BattleTech/EquipmentControllerTests.swift index 4ec5316..3487a0b 100644 --- a/Tests/AppTests/Controllers/BattleTech/EquipmentControllerTests.swift +++ b/Tests/AppTests/Controllers/BattleTech/EquipmentControllerTests.swift @@ -5,98 +5,95 @@ // import Fluent -import XCTVapor +import Testing +import VaporTesting @testable import App -final class EquipmentControllerTests: XCTestCase { - var app: Application! - var path = "/battletech/equipment" - - override func setUp() async throws { - self.app = try await Application.make(.testing) - try await configure(app) - try await app.autoMigrate() - } - - override func tearDown() async throws { - try await app.autoRevert() - try await self.app.asyncShutdown() - self.app = nil - } - - func testIndex() async throws { - _ = try await BattleTech.Equipment.create(on: app.db) - let equipmentCount = try await BattleTech.Equipment.query(on: app.db).count() - - try app.test( - .GET, path, - loggedInRequest: false, - afterResponse: { response in - let equipment = try response.content.decode(Page.self) - XCTAssertEqual(equipment.metadata.total, equipmentCount) - }) +@Suite(.serialized, .databaseSerialized) +struct EquipmentControllerTests { + let path = "/battletech/equipment" + + @Test + func index() async throws { + try await withTestApp { app in + _ = try await BattleTech.Equipment.create(on: app.db) + let equipmentCount = try await BattleTech.Equipment.query(on: app.db).count() + + try await app.test( + .GET, path, + loggedInRequest: false, + afterResponse: { response in + let equipment = try response.content.decode(Page.self) + #expect(equipment.metadata.total == equipmentCount) + }) + } } - func testShow() async throws { - let equipment = try await BattleTech.Equipment.create(on: app.db) - let showPath = "\(path)/\(equipment.id!)" - - try app.test( - .GET, showPath, - loggedInRequest: false, - afterResponse: { response in - let returnedEquipment = try response.content.decode(BattleTech.Equipment.self) - XCTAssertEqual(equipment.name, returnedEquipment.name) - }) + @Test + func show() async throws { + try await withTestApp { app in + let equipment = try await BattleTech.Equipment.create(on: app.db) + let showPath = "\(path)/\(equipment.id!)" + + try await app.test( + .GET, showPath, + loggedInRequest: false, + afterResponse: { response in + let returnedEquipment = try response.content.decode(BattleTech.Equipment.self) + #expect(equipment.name == returnedEquipment.name) + }) + } } - func testShowNotFound() async throws { - let notFoundPath = "\(path)/NOT-A-UUID" - - try app.test( - .GET, notFoundPath, - loggedInRequest: false, - afterResponse: { response in - XCTAssertEqual(response.status, .notFound) - }) + @Test + func showNotFound() async throws { + try await withTestApp { app in + let notFoundPath = "\(path)/NOT-A-UUID" + + try await app.test( + .GET, notFoundPath, + loggedInRequest: false, + afterResponse: { response in + #expect(response.status == .notFound) + }) + } } - func testDelete() async throws { - let equipment = try await BattleTech.Equipment.create(on: app.db) - let showPath = "\(path)/\(equipment.id!)" - - try app.test( - .DELETE, showPath, - loggedInRequest: false, - afterResponse: { response in - XCTAssertEqual(response.status, .noContent) - }) + @Test + func delete() async throws { + try await withTestApp { app in + let equipment = try await BattleTech.Equipment.create(on: app.db) + let showPath = "\(path)/\(equipment.id!)" + + try await app.test( + .DELETE, showPath, + loggedInRequest: false, + afterResponse: { response in + #expect(response.status == .noContent) + }) + } } - func testImport() async throws { - let (testFileHandle, testFileRegion) = try await app.fileio.openFile( - path: "Tests/Resources/BattleTech/misc.csv", - eventLoop: app.eventLoopGroup.next() - ).get() - - let testFileByteBuffer = try await app.fileio.read( - fileRegion: testFileRegion, allocator: .init()) - let equipmentMassImport = EquipmentMassImport( - file: File(data: testFileByteBuffer, filename: "misc.csv")) - try testFileHandle.close() - - let massImportPath = "\(path)/import" - - try app.test( - .POST, massImportPath, - loggedInRequest: false, - beforeRequest: { request in - try request.content.encode(equipmentMassImport) - }, - afterResponse: { response in - XCTAssertEqual(response.status, .created) - XCTAssertNotNil(app.queues.queue.pop()) - }) + @Test + func `import`() async throws { + try await withTestApp { app in + let testFileByteBuffer = try await TestResources.buffer(for: "BattleTech/misc.csv") + let equipmentMassImport = EquipmentMassImport( + file: File(data: testFileByteBuffer, filename: "misc.csv")) + + let massImportPath = "\(path)/import" + + try await app.test( + .POST, massImportPath, + loggedInRequest: false, + beforeRequest: { request in + try request.content.encode(equipmentMassImport) + }, + afterResponse: { response in + #expect(response.status == .created) + #expect(app.queues.queue.pop() != nil) + }) + } } } diff --git a/Tests/AppTests/Controllers/BattleTech/ErasControllerTests.swift b/Tests/AppTests/Controllers/BattleTech/ErasControllerTests.swift index 1e30450..dde9a03 100644 --- a/Tests/AppTests/Controllers/BattleTech/ErasControllerTests.swift +++ b/Tests/AppTests/Controllers/BattleTech/ErasControllerTests.swift @@ -5,142 +5,135 @@ // import Fluent -import XCTVapor +import Testing +import VaporTesting @testable import App -final class ErasControllerTests: XCTestCase { - var app: Application! - - var path = "/battletech/eras" - - override func setUp() async throws { - self.app = try await Application.make(.testing) - try await configure(app) - try await app.autoMigrate() - } - - override func tearDown() async throws { - try await app.autoRevert() - try await self.app.asyncShutdown() - self.app = nil +@Suite(.serialized, .databaseSerialized) +struct ErasControllerTests { + let path = "/battletech/eras" + + @Test + func index() async throws { + try await withTestApp { app in + _ = try await BattleTech.Era.create(on: app.db) + let eraCount = try await BattleTech.Era.query(on: app.db).count() + + try await app.test( + .GET, path, + loggedInRequest: false, + afterResponse: { response in + let eras = try response.content.decode([BattleTech.Era].self) + #expect(eras.count == eraCount) + }) + } } - func testIndex() async throws { - _ = try await BattleTech.Era.create(on: app.db) - let eraCount = try await BattleTech.Era.query(on: app.db).count() - - try app.test( - .GET, path, - loggedInRequest: false, - afterResponse: { response in - let eras = try response.content.decode([BattleTech.Era].self) - XCTAssertEqual(eras.count, eraCount) - }) + @Test + func show() async throws { + try await withTestApp { app in + let era = try await BattleTech.Era.create(on: app.db) + let showPath = "\(path)/\(era.id!)" + + try await app.test( + .GET, showPath, + loggedInRequest: false, + afterResponse: { response in + let returnedEra = try response.content.decode(BattleTech.Era.self) + #expect(era.name == returnedEra.name) + }) + } } - func testShow() async throws { - let era = try await BattleTech.Era.create(on: app.db) - let showPath = "\(path)/\(era.id!)" - - try app.test( - .GET, showPath, - loggedInRequest: false, - afterResponse: { response in - let returnedEra = try response.content.decode(BattleTech.Era.self) - XCTAssertEqual(era.name, returnedEra.name) - }) + @Test + func showNotFound() async throws { + try await withTestApp { app in + let notFoundPath = "\(path)/NOT-A-UUID" + + try await app.test( + .GET, notFoundPath, + loggedInRequest: false, + afterResponse: { response in + #expect(response.status == .notFound) + }) + } } - func testShowNotFound() async throws { - let notFoundPath = "\(path)/NOT-A-UUID" - - try app.test( - .GET, notFoundPath, - loggedInRequest: false, - afterResponse: { response in - XCTAssertEqual(response.status, .notFound) - }) - } - - func testDelete() async throws { - let era = try await BattleTech.Era.create(on: app.db) - let showPath = "\(path)/\(era.id!)" - - try app.test( - .DELETE, showPath, - loggedInRequest: false, - afterResponse: { response in - XCTAssertEqual(response.status, .noContent) - }) + @Test + func delete() async throws { + try await withTestApp { app in + let era = try await BattleTech.Era.create(on: app.db) + let showPath = "\(path)/\(era.id!)" + + try await app.test( + .DELETE, showPath, + loggedInRequest: false, + afterResponse: { response in + #expect(response.status == .noContent) + }) + } } - func testImport() async throws { - let eraCount = try await BattleTech.Era.query(on: app.db).count() - - let (testFileHandle, testFileRegion) = try await app.fileio.openFile( - path: "Tests/Resources/BattleTech/eras.xml", - eventLoop: app.eventLoopGroup.next() - ).get() - - let testFileByteBuffer = try await app.fileio.read( - fileRegion: testFileRegion, allocator: .init()) - let eraMassImport = EraMassImport( - file: File(data: testFileByteBuffer, filename: "eras.xml")) - try testFileHandle.close() - - let massImportPath = "\(path)/import" - - try app.test( - .POST, massImportPath, - loggedInRequest: false, - beforeRequest: { request in - try request.content.encode(eraMassImport) - }, - afterResponse: { response in - XCTAssertEqual(response.status, .created) - }) - - let postEraCount = try await BattleTech.Era.query(on: app.db).count() - XCTAssertNotEqual(eraCount, postEraCount) + @Test + func `import`() async throws { + try await withTestApp { app in + let eraCount = try await BattleTech.Era.query(on: app.db).count() + + let testFileByteBuffer = try await TestResources.buffer(for: "BattleTech/eras.xml") + let eraMassImport = EraMassImport( + file: File(data: testFileByteBuffer, filename: "eras.xml")) + + let massImportPath = "\(path)/import" + + try await app.test( + .POST, massImportPath, + loggedInRequest: false, + beforeRequest: { request in + try request.content.encode(eraMassImport) + }, + afterResponse: { response in + #expect(response.status == .created) + }) + + let postEraCount = try await BattleTech.Era.query(on: app.db).count() + #expect(eraCount != postEraCount) + } } - func testDuplicateImport() async throws { - let (testFileHandle, testFileRegion) = try await app.fileio.openFile( - path: "Tests/Resources/BattleTech/eras.xml", - eventLoop: app.eventLoopGroup.next() - ).get() - - let testFileByteBuffer = try await app.fileio.read( - fileRegion: testFileRegion, allocator: .init()) - let eraMassImport = EraMassImport( - file: File(data: testFileByteBuffer, filename: "eras.xml")) - try testFileHandle.close() - - let massImportPath = "\(path)/import" - - try app.test( - .POST, massImportPath, - loggedInRequest: false, - beforeRequest: { request in - try request.content.encode(eraMassImport) - }, - afterResponse: { response in - XCTAssertEqual(response.status, .created) - }) - - let eraCount = try await BattleTech.Era.query(on: app.db).count() - - try await app.test( - .POST, massImportPath, - beforeRequest: { request in - try request.content.encode(eraMassImport) - }, - afterResponse: { response in - let postCount = try await BattleTech.Era.query(on: app.db).count() - - XCTAssertEqual(response.status, .created) - XCTAssertEqual(eraCount, postCount) - }) + @Test + func duplicateImport() async throws { + try await withTestApp { app in + let testFileByteBuffer = try await TestResources.buffer(for: "BattleTech/eras.xml") + let eraMassImport = EraMassImport( + file: File(data: testFileByteBuffer, filename: "eras.xml")) + + let massImportPath = "\(path)/import" + + try await app.test( + .POST, massImportPath, + loggedInRequest: false, + beforeRequest: { request in + try request.content.encode(eraMassImport) + }, + afterResponse: { response in + #expect(response.status == .created) + }) + + let eraCount = try await BattleTech.Era.query(on: app.db).count() + + try await app.test( + .POST, massImportPath, + loggedInRequest: false, + beforeRequest: { request in + try request.content.encode(eraMassImport) + }, + afterResponse: { response in + let postCount = try await BattleTech.Era.query(on: app.db).count() + + #expect(response.status == .created) + #expect(eraCount == postCount) + }) + } } } diff --git a/Tests/AppTests/Controllers/BattleTech/FactionsControllerTests.swift b/Tests/AppTests/Controllers/BattleTech/FactionsControllerTests.swift index 97f61d3..ce67557 100644 --- a/Tests/AppTests/Controllers/BattleTech/FactionsControllerTests.swift +++ b/Tests/AppTests/Controllers/BattleTech/FactionsControllerTests.swift @@ -5,141 +5,135 @@ // import Fluent -import XCTVapor +import Testing +import VaporTesting @testable import App -final class FactionsControllerTests: XCTestCase { - var app: Application! - var path = "/battletech/factions" - - override func setUp() async throws { - self.app = try await Application.make(.testing) - try await configure(app) - try await app.autoMigrate() - } - - override func tearDown() async throws { - try await app.autoRevert() - try await self.app.asyncShutdown() - self.app = nil +@Suite(.serialized, .databaseSerialized) +struct FactionsControllerTests { + let path = "/battletech/factions" + + @Test + func index() async throws { + try await withTestApp { app in + _ = try await BattleTech.Faction.create(on: app.db) + let factionCount = try await BattleTech.Faction.query(on: app.db).count() + + try await app.test( + .GET, path, + loggedInRequest: false, + afterResponse: { response in + let factions = try response.content.decode([BattleTech.Faction].self) + #expect(factions.count == factionCount) + }) + } } - func testIndex() async throws { - _ = try await BattleTech.Faction.create(on: app.db) - let factionCount = try await BattleTech.Faction.query(on: app.db).count() - - try app.test( - .GET, path, - loggedInRequest: false, - afterResponse: { response in - let factions = try response.content.decode([BattleTech.Faction].self) - XCTAssertEqual(factions.count, factionCount) - }) + @Test + func show() async throws { + try await withTestApp { app in + let faction = try await BattleTech.Faction.create(on: app.db) + let showPath = "\(path)/\(faction.id!)" + + try await app.test( + .GET, showPath, + loggedInRequest: false, + afterResponse: { response in + let returnedFaction = try response.content.decode(BattleTech.Faction.self) + #expect(faction.factionKey == returnedFaction.factionKey) + }) + } } - func testShow() async throws { - let faction = try await BattleTech.Faction.create(on: app.db) - let showPath = "\(path)/\(faction.id!)" - - try app.test( - .GET, showPath, - loggedInRequest: false, - afterResponse: { response in - let returnedFaction = try response.content.decode(BattleTech.Faction.self) - XCTAssertEqual(faction.factionKey, returnedFaction.factionKey) - }) + @Test + func showNotFound() async throws { + try await withTestApp { app in + let notFoundPath = "\(path)/NOT-A-UUID" + + try await app.test( + .GET, notFoundPath, + loggedInRequest: false, + afterResponse: { response in + #expect(response.status == .notFound) + }) + } } - func testShowNotFound() async throws { - let notFoundPath = "\(path)/NOT-A-UUID" - - try app.test( - .GET, notFoundPath, - loggedInRequest: false, - afterResponse: { response in - XCTAssertEqual(response.status, .notFound) - }) - } - - func testDelete() async throws { - let faction = try await BattleTech.Faction.create(on: app.db) - let showPath = "\(path)/\(faction.id!)" - - try app.test( - .DELETE, showPath, - loggedInRequest: false, - afterResponse: { response in - XCTAssertEqual(response.status, .noContent) - }) + @Test + func delete() async throws { + try await withTestApp { app in + let faction = try await BattleTech.Faction.create(on: app.db) + let showPath = "\(path)/\(faction.id!)" + + try await app.test( + .DELETE, showPath, + loggedInRequest: false, + afterResponse: { response in + #expect(response.status == .noContent) + }) + } } - func testImport() async throws { - let factionCount = try await BattleTech.Faction.query(on: app.db).count() - - let (testFileHandle, testFileRegion) = try await app.fileio.openFile( - path: "Tests/Resources/BattleTech/factions.xml", - eventLoop: app.eventLoopGroup.next() - ).get() - - let testFileByteBuffer = try await app.fileio.read( - fileRegion: testFileRegion, allocator: .init()) - let factionMassImport = FactionMassImport( - file: File(data: testFileByteBuffer, filename: "factions.xml")) - try testFileHandle.close() - - let massImportPath = "\(path)/import" - - try app.test( - .POST, massImportPath, - loggedInRequest: false, - beforeRequest: { request in - try request.content.encode(factionMassImport) - }, - afterResponse: { response in - XCTAssertEqual(response.status, .created) - }) - - let postFactionCount = try await BattleTech.Faction.query(on: app.db).count() - XCTAssertNotEqual(factionCount, postFactionCount) + @Test + func `import`() async throws { + try await withTestApp { app in + let factionCount = try await BattleTech.Faction.query(on: app.db).count() + + let testFileByteBuffer = try await TestResources.buffer(for: "BattleTech/factions.xml") + let factionMassImport = FactionMassImport( + file: File(data: testFileByteBuffer, filename: "factions.xml")) + + let massImportPath = "\(path)/import" + + try await app.test( + .POST, massImportPath, + loggedInRequest: false, + beforeRequest: { request in + try request.content.encode(factionMassImport) + }, + afterResponse: { response in + #expect(response.status == .created) + }) + + let postFactionCount = try await BattleTech.Faction.query(on: app.db).count() + #expect(factionCount != postFactionCount) + } } - func testDuplicateImport() async throws { - let (testFileHandle, testFileRegion) = try await app.fileio.openFile( - path: "Tests/Resources/BattleTech/factions.xml", - eventLoop: app.eventLoopGroup.next() - ).get() - - let testFileByteBuffer = try await app.fileio.read( - fileRegion: testFileRegion, allocator: .init()) - let factionMassImport = FactionMassImport( - file: File(data: testFileByteBuffer, filename: "factions.xml")) - try testFileHandle.close() - - let massImportPath = "\(path)/import" - - try app.test( - .POST, massImportPath, - loggedInRequest: false, - beforeRequest: { request in - try request.content.encode(factionMassImport) - }, - afterResponse: { response in - XCTAssertEqual(response.status, .created) - }) - - let factionCount = try await BattleTech.Faction.query(on: app.db).count() - - try await app.test( - .POST, massImportPath, - beforeRequest: { request in - try request.content.encode(factionMassImport) - }, - afterResponse: { response in - let postCount = try await BattleTech.Faction.query(on: app.db).count() - - XCTAssertEqual(response.status, .created) - XCTAssertEqual(factionCount, postCount) - }) + @Test + func duplicateImport() async throws { + try await withTestApp { app in + let testFileByteBuffer = try await TestResources.buffer(for: "BattleTech/factions.xml") + let factionMassImport = FactionMassImport( + file: File(data: testFileByteBuffer, filename: "factions.xml")) + + let massImportPath = "\(path)/import" + + try await app.test( + .POST, massImportPath, + loggedInRequest: false, + beforeRequest: { request in + try request.content.encode(factionMassImport) + }, + afterResponse: { response in + #expect(response.status == .created) + }) + + let factionCount = try await BattleTech.Faction.query(on: app.db).count() + + try await app.test( + .POST, massImportPath, + loggedInRequest: false, + beforeRequest: { request in + try request.content.encode(factionMassImport) + }, + afterResponse: { response in + let postCount = try await BattleTech.Faction.query(on: app.db).count() + + #expect(response.status == .created) + #expect(factionCount == postCount) + }) + } } } diff --git a/Tests/AppTests/Controllers/BattleTech/MunitionTypesControllerTests.swift b/Tests/AppTests/Controllers/BattleTech/MunitionTypesControllerTests.swift index b760a8d..6dc2c19 100644 --- a/Tests/AppTests/Controllers/BattleTech/MunitionTypesControllerTests.swift +++ b/Tests/AppTests/Controllers/BattleTech/MunitionTypesControllerTests.swift @@ -5,85 +5,89 @@ // import Fluent -import XCTVapor +import Testing +import VaporTesting @testable import App -final class MunitionTypesControllerTests: XCTestCase { - var path = "/battletech/munition-types" - var app: Application! +@Suite(.serialized, .databaseSerialized) +struct MunitionTypesControllerTests { + let path = "/battletech/munition-types" - override func setUp() async throws { - self.app = try await Application.make(.testing) - try await configure(app) - try await app.autoMigrate() - } - - override func tearDown() async throws { - try await app.autoRevert() - try await self.app.asyncShutdown() - self.app = nil - } - - func testIndex() async throws { - _ = try await BattleTech.MunitionType.create(on: app.db) - let munitionTypeCount = try await BattleTech.MunitionType.query(on: app.db).count() + @Test + func index() async throws { + try await withTestApp { app in + _ = try await BattleTech.MunitionType.create(on: app.db) + let munitionTypeCount = try await BattleTech.MunitionType.query(on: app.db).count() - try app.test( - .GET, path, - loggedInRequest: false, - afterResponse: { response in - let munitionTypes = try response.content.decode([BattleTech.MunitionType].self) - XCTAssertEqual(munitionTypes.count, munitionTypeCount) - }) + try await app.test( + .GET, path, + loggedInRequest: false, + afterResponse: { response in + let munitionTypes = try response.content.decode([BattleTech.MunitionType].self) + #expect(munitionTypes.count == munitionTypeCount) + }) + } } - func testShow() async throws { - let munitionType = try await BattleTech.MunitionType.create(on: app.db) - let showPath = "\(path)/\(munitionType.id!)" + @Test + func show() async throws { + try await withTestApp { app in + let munitionType = try await BattleTech.MunitionType.create(on: app.db) + let showPath = "\(path)/\(munitionType.id!)" - try app.test( - .GET, showPath, - loggedInRequest: false, - afterResponse: { response in - let returnedMunitionType = try response.content.decode(BattleTech.MunitionType.self) - XCTAssertEqual(munitionType.name, returnedMunitionType.name) - }) + try await app.test( + .GET, showPath, + loggedInRequest: false, + afterResponse: { response in + let returnedMunitionType = try response.content.decode(BattleTech.MunitionType.self) + #expect(munitionType.name == returnedMunitionType.name) + }) + } } - func testShowNotFound() async throws { - let notFoundPath = "\(path)/NOT-A-UUID" + @Test + func showNotFound() async throws { + try await withTestApp { app in + let notFoundPath = "\(path)/NOT-A-UUID" - try app.test( - .GET, notFoundPath, - loggedInRequest: false, - afterResponse: { response in - XCTAssertEqual(response.status, .notFound) - }) + try await app.test( + .GET, notFoundPath, + loggedInRequest: false, + afterResponse: { response in + #expect(response.status == .notFound) + }) + } } - func testDelete() async throws { - let munitionType = try await BattleTech.MunitionType.create(on: app.db) - let showPath = "\(path)/\(munitionType.id!)" + @Test + func delete() async throws { + try await withTestApp { app in + let munitionType = try await BattleTech.MunitionType.create(on: app.db) + let showPath = "\(path)/\(munitionType.id!)" - try app.test( - .DELETE, showPath, - loggedInRequest: false, - afterResponse: { response in - XCTAssertEqual(response.status, .noContent) - }) + try await app.test( + .DELETE, showPath, + loggedInRequest: false, + afterResponse: { response in + #expect(response.status == .noContent) + }) + } } - func testAmmo() async throws { - let ammo = try await BattleTech.Ammo.create(on: app.db) - let showPath = "\(path)/\(ammo.$munitionType.id)/ammo" + @Test + func ammo() async throws { + try await withTestApp { app in + let ammo = try await BattleTech.Ammo.create(on: app.db) + let showPath = "\(path)/\(ammo.$munitionType.id)/ammo" - try app.test( - .GET, showPath, - loggedInRequest: false, - afterResponse: { response in - let returnedAmmo = try response.content.decode(Page.self) - XCTAssertEqual(1, returnedAmmo.metadata.total) - }) + try await app.test( + .GET, showPath, + loggedInRequest: false, + afterResponse: { response in + let returnedAmmo = try response.content.decode(Page.self) + #expect(1 == returnedAmmo.metadata.total) + }) + } } } diff --git a/Tests/AppTests/Controllers/BattleTech/RulesControllerTests.swift b/Tests/AppTests/Controllers/BattleTech/RulesControllerTests.swift index 9e71bee..19d3e2f 100644 --- a/Tests/AppTests/Controllers/BattleTech/RulesControllerTests.swift +++ b/Tests/AppTests/Controllers/BattleTech/RulesControllerTests.swift @@ -5,120 +5,130 @@ // import Fluent -import XCTVapor +import Testing +import VaporTesting @testable import App -final class RulesControllerTests: XCTestCase { - var path = "/battletech/rules" - var app: Application! - - override func setUp() async throws { - self.app = try await Application.make(.testing) - try await configure(app) - try await app.autoMigrate() +@Suite(.serialized, .databaseSerialized) +struct RulesControllerTests { + let path = "/battletech/rules" + + @Test + func index() async throws { + try await withTestApp { app in + _ = try await BattleTech.Rule.create(on: app.db) + let ruleCount = try await BattleTech.Rule.query(on: app.db).count() + + try await app.test( + .GET, path, + loggedInRequest: false, + afterResponse: { response in + let rules = try response.content.decode([BattleTech.Rule].self) + #expect(rules.count == ruleCount) + }) + } } - override func tearDown() async throws { - try await app.autoRevert() - try await self.app.asyncShutdown() - self.app = nil + @Test + func show() async throws { + try await withTestApp { app in + let rule = try await BattleTech.Rule.create(on: app.db) + let showPath = "\(path)/\(rule.id!)" + + try await app.test( + .GET, showPath, + loggedInRequest: false, + afterResponse: { response in + let returnedRule = try response.content.decode(BattleTech.Rule.self) + #expect(rule.name == returnedRule.name) + }) + } } - func testIndex() async throws { - _ = try await BattleTech.Rule.create(on: app.db) - let ruleCount = try await BattleTech.Rule.query(on: app.db).count() - - try app.test( - .GET, path, - loggedInRequest: false, - afterResponse: { response in - let rules = try response.content.decode([BattleTech.Rule].self) - XCTAssertEqual(rules.count, ruleCount) - }) + @Test + func showNotFound() async throws { + try await withTestApp { app in + let notFoundPath = "\(path)/NOT-A-UUID" + + try await app.test( + .GET, notFoundPath, + loggedInRequest: false, + afterResponse: { response in + #expect(response.status == .notFound) + }) + } } - func testShow() async throws { - let rule = try await BattleTech.Rule.create(on: app.db) - let showPath = "\(path)/\(rule.id!)" - - try app.test( - .GET, showPath, - loggedInRequest: false, - afterResponse: { response in - let returnedRule = try response.content.decode(BattleTech.Rule.self) - XCTAssertEqual(rule.name, returnedRule.name) - }) + @Test + func delete() async throws { + try await withTestApp { app in + let rule = try await BattleTech.Rule.create(on: app.db) + let showPath = "\(path)/\(rule.id!)" + + try await app.test( + .DELETE, showPath, + loggedInRequest: false, + afterResponse: { response in + #expect(response.status == .noContent) + }) + } } - func testShowNotFound() async throws { - let notFoundPath = "\(path)/NOT-A-UUID" - - try app.test( - .GET, notFoundPath, - loggedInRequest: false, - afterResponse: { response in - XCTAssertEqual(response.status, .notFound) - }) + @Test + func ammo() async throws { + try await withTestApp { app in + let ammo = try await BattleTech.Ammo.create(on: app.db) + try await ammo.$rules.load(on: app.db) + let rule = ammo.rules.first! + + let showPath = "\(path)/\(rule.id!)/ammo" + + try await app.test( + .GET, showPath, + loggedInRequest: false, + afterResponse: { response in + let returnedAmmo = try response.content.decode(Page.self) + #expect(1 == returnedAmmo.metadata.total) + }) + } } - func testDelete() async throws { - let rule = try await BattleTech.Rule.create(on: app.db) - let showPath = "\(path)/\(rule.id!)" - - try app.test( - .DELETE, showPath, - loggedInRequest: false, - afterResponse: { response in - XCTAssertEqual(response.status, .noContent) - }) - } - - func testAmmo() async throws { - let ammo = try await BattleTech.Ammo.create(on: app.db) - try await ammo.$rules.load(on: app.db) - let rule = ammo.rules.first! - - let showPath = "\(path)/\(rule.id!)/ammo" - - try app.test( - .GET, showPath, - loggedInRequest: false, - afterResponse: { response in - let returnedAmmo = try response.content.decode(Page.self) - XCTAssertEqual(1, returnedAmmo.metadata.total) - }) + @Test + func equipment() async throws { + try await withTestApp { app in + let equipment = try await BattleTech.Equipment.create(on: app.db) + try await equipment.$rules.load(on: app.db) + let rule = equipment.rules.first! + + let showPath = "\(path)/\(rule.id!)/equipment" + + try await app.test( + .GET, showPath, + loggedInRequest: false, + afterResponse: { response in + let returnedEquipment = try response.content.decode(Page.self) + #expect(1 == returnedEquipment.metadata.total) + }) + } } - func testEquipment() async throws { - let equipment = try await BattleTech.Equipment.create(on: app.db) - try await equipment.$rules.load(on: app.db) - let rule = equipment.rules.first! - - let showPath = "\(path)/\(rule.id!)/equipment" - - try app.test( - .GET, showPath, - loggedInRequest: false, - afterResponse: { response in - let returnedEquipment = try response.content.decode(Page.self) - XCTAssertEqual(1, returnedEquipment.metadata.total) - }) - } - - func testWeapons() async throws { - let weapon = try await BattleTech.Weapon.create(on: app.db) - try await weapon.$rules.load(on: app.db) - let rule = weapon.rules.first! - - let showPath = "\(path)/\(rule.id!)/weapons" - - try app.test( - .GET, showPath, - loggedInRequest: false, - afterResponse: { response in - let returnedWeapons = try response.content.decode(Page.self) - XCTAssertEqual(1, returnedWeapons.metadata.total) - }) + @Test + func weapons() async throws { + try await withTestApp { app in + let weapon = try await BattleTech.Weapon.create(on: app.db) + try await weapon.$rules.load(on: app.db) + let rule = weapon.rules.first! + + let showPath = "\(path)/\(rule.id!)/weapons" + + try await app.test( + .GET, showPath, + loggedInRequest: false, + afterResponse: { response in + let returnedWeapons = try response.content.decode(Page.self) + #expect(1 == returnedWeapons.metadata.total) + }) + } } } diff --git a/Tests/AppTests/Controllers/BattleTech/TechBaseControllerTests.swift b/Tests/AppTests/Controllers/BattleTech/TechBaseControllerTests.swift index a812009..243dca7 100644 --- a/Tests/AppTests/Controllers/BattleTech/TechBaseControllerTests.swift +++ b/Tests/AppTests/Controllers/BattleTech/TechBaseControllerTests.swift @@ -5,111 +5,121 @@ // import Fluent -import XCTVapor +import Testing +import VaporTesting @testable import App -final class TechBaseControllerTests: XCTestCase { - var path = "/battletech/tech-bases" - var app: Application! - - override func setUp() async throws { - self.app = try await Application.make(.testing) - try await configure(app) - try await app.autoMigrate() - } - - override func tearDown() async throws { - try await app.autoRevert() - try await self.app.asyncShutdown() - self.app = nil +@Suite(.serialized, .databaseSerialized) +struct TechBaseControllerTests { + let path = "/battletech/tech-bases" + + @Test + func index() async throws { + try await withTestApp { app in + _ = try await BattleTech.TechBase.create(on: app.db) + let techBaseCount = try await BattleTech.TechBase.query(on: app.db).count() + + try await app.test( + .GET, path, + loggedInRequest: false, + afterResponse: { response in + let techBases = try response.content.decode([BattleTech.TechBase].self) + #expect(techBases.count == techBaseCount) + }) + } } - func testIndex() async throws { - _ = try await BattleTech.TechBase.create(on: app.db) - let techBaseCount = try await BattleTech.TechBase.query(on: app.db).count() - - try app.test( - .GET, path, - loggedInRequest: false, - afterResponse: { response in - let techBases = try response.content.decode([BattleTech.TechBase].self) - XCTAssertEqual(techBases.count, techBaseCount) - }) + @Test + func show() async throws { + try await withTestApp { app in + let techBase = try await BattleTech.TechBase.create(on: app.db) + let showPath = "\(path)/\(techBase.id!)" + + try await app.test( + .GET, showPath, + loggedInRequest: false, + afterResponse: { response in + let returnedTechBase = try response.content.decode(BattleTech.TechBase.self) + #expect(techBase.name == returnedTechBase.name) + }) + } } - func testShow() async throws { - let techBase = try await BattleTech.TechBase.create(on: app.db) - let showPath = "\(path)/\(techBase.id!)" - - try app.test( - .GET, showPath, - loggedInRequest: false, - afterResponse: { response in - let returnedTechBase = try response.content.decode(BattleTech.TechBase.self) - XCTAssertEqual(techBase.name, returnedTechBase.name) - }) + @Test + func showNotFound() async throws { + try await withTestApp { app in + let notFoundPath = "\(path)/NOT-A-UUID" + + try await app.test( + .GET, notFoundPath, + loggedInRequest: false, + afterResponse: { response in + #expect(response.status == .notFound) + }) + } } - func testShowNotFound() async throws { - let notFoundPath = "\(path)/NOT-A-UUID" - - try app.test( - .GET, notFoundPath, - loggedInRequest: false, - afterResponse: { response in - XCTAssertEqual(response.status, .notFound) - }) - } - - func testDelete() async throws { - let techBase = try await BattleTech.TechBase.create(on: app.db) - let showPath = "\(path)/\(techBase.id!)" - - try app.test( - .DELETE, showPath, - loggedInRequest: false, - afterResponse: { response in - XCTAssertEqual(response.status, .noContent) - }) + @Test + func delete() async throws { + try await withTestApp { app in + let techBase = try await BattleTech.TechBase.create(on: app.db) + let showPath = "\(path)/\(techBase.id!)" + + try await app.test( + .DELETE, showPath, + loggedInRequest: false, + afterResponse: { response in + #expect(response.status == .noContent) + }) + } } - func testAmmo() async throws { - let ammo = try await BattleTech.Ammo.create(on: app.db) - let showPath = "\(path)/\(ammo.$techBase.id)/ammo" - - try app.test( - .GET, showPath, - loggedInRequest: false, - afterResponse: { response in - let returnedAmmo = try response.content.decode(Page.self) - XCTAssertEqual(1, returnedAmmo.metadata.total) - }) + @Test + func ammo() async throws { + try await withTestApp { app in + let ammo = try await BattleTech.Ammo.create(on: app.db) + let showPath = "\(path)/\(ammo.$techBase.id)/ammo" + + try await app.test( + .GET, showPath, + loggedInRequest: false, + afterResponse: { response in + let returnedAmmo = try response.content.decode(Page.self) + #expect(1 == returnedAmmo.metadata.total) + }) + } } - func testEquipment() async throws { - let equipment = try await BattleTech.Equipment.create(on: app.db) - let showPath = "\(path)/\(equipment.$techBase.id)/equipment" - - try app.test( - .GET, showPath, - loggedInRequest: false, - afterResponse: { response in - let returnedEquipment = try response.content.decode(Page.self) - XCTAssertEqual(1, returnedEquipment.metadata.total) - }) + @Test + func equipment() async throws { + try await withTestApp { app in + let equipment = try await BattleTech.Equipment.create(on: app.db) + let showPath = "\(path)/\(equipment.$techBase.id)/equipment" + + try await app.test( + .GET, showPath, + loggedInRequest: false, + afterResponse: { response in + let returnedEquipment = try response.content.decode(Page.self) + #expect(1 == returnedEquipment.metadata.total) + }) + } } - func testWeapons() async throws { - let weapon = try await BattleTech.Weapon.create(on: app.db) - let showPath = "\(path)/\(weapon.$techBase.id)/weapons" - - try app.test( - .GET, showPath, - loggedInRequest: false, - afterResponse: { response in - let returnedWeapons = try response.content.decode(Page.self) - XCTAssertEqual(1, returnedWeapons.metadata.total) - }) + @Test + func weapons() async throws { + try await withTestApp { app in + let weapon = try await BattleTech.Weapon.create(on: app.db) + let showPath = "\(path)/\(weapon.$techBase.id)/weapons" + + try await app.test( + .GET, showPath, + loggedInRequest: false, + afterResponse: { response in + let returnedWeapons = try response.content.decode(Page.self) + #expect(1 == returnedWeapons.metadata.total) + }) + } } } diff --git a/Tests/AppTests/Controllers/BattleTech/TechLevelControllerTests.swift b/Tests/AppTests/Controllers/BattleTech/TechLevelControllerTests.swift index cb9e8c3..168414e 100644 --- a/Tests/AppTests/Controllers/BattleTech/TechLevelControllerTests.swift +++ b/Tests/AppTests/Controllers/BattleTech/TechLevelControllerTests.swift @@ -5,111 +5,121 @@ // import Fluent -import XCTVapor +import Testing +import VaporTesting @testable import App -final class TechLevelControllerTests: XCTestCase { - var path = "/battletech/tech-levels" - var app: Application! - - override func setUp() async throws { - self.app = try await Application.make(.testing) - try await configure(app) - try await app.autoMigrate() - } - - override func tearDown() async throws { - try await app.autoRevert() - try await self.app.asyncShutdown() - self.app = nil +@Suite(.serialized, .databaseSerialized) +struct TechLevelControllerTests { + let path = "/battletech/tech-levels" + + @Test + func index() async throws { + try await withTestApp { app in + _ = try await BattleTech.TechLevel.create(on: app.db) + let techLevelCount = try await BattleTech.TechLevel.query(on: app.db).count() + + try await app.test( + .GET, path, + loggedInRequest: false, + afterResponse: { response in + let techLevels = try response.content.decode([BattleTech.TechLevel].self) + #expect(techLevels.count == techLevelCount) + }) + } } - func testIndex() async throws { - _ = try await BattleTech.TechLevel.create(on: app.db) - let techLevelCount = try await BattleTech.TechLevel.query(on: app.db).count() - - try app.test( - .GET, path, - loggedInRequest: false, - afterResponse: { response in - let techLevels = try response.content.decode([BattleTech.TechLevel].self) - XCTAssertEqual(techLevels.count, techLevelCount) - }) + @Test + func show() async throws { + try await withTestApp { app in + let techLevel = try await BattleTech.TechLevel.create(on: app.db) + let showPath = "\(path)/\(techLevel.id!)" + + try await app.test( + .GET, showPath, + loggedInRequest: false, + afterResponse: { response in + let returnedTechLevel = try response.content.decode(BattleTech.TechLevel.self) + #expect(techLevel.name == returnedTechLevel.name) + }) + } } - func testShow() async throws { - let techLevel = try await BattleTech.TechLevel.create(on: app.db) - let showPath = "\(path)/\(techLevel.id!)" - - try app.test( - .GET, showPath, - loggedInRequest: false, - afterResponse: { response in - let returnedTechLevel = try response.content.decode(BattleTech.TechLevel.self) - XCTAssertEqual(techLevel.name, returnedTechLevel.name) - }) + @Test + func showNotFound() async throws { + try await withTestApp { app in + let notFoundPath = "\(path)/NOT-A-UUID" + + try await app.test( + .GET, notFoundPath, + loggedInRequest: false, + afterResponse: { response in + #expect(response.status == .notFound) + }) + } } - func testShowNotFound() async throws { - let notFoundPath = "\(path)/NOT-A-UUID" - - try app.test( - .GET, notFoundPath, - loggedInRequest: false, - afterResponse: { response in - XCTAssertEqual(response.status, .notFound) - }) - } - - func testDelete() async throws { - let techLevel = try await BattleTech.TechLevel.create(on: app.db) - let showPath = "\(path)/\(techLevel.id!)" - - try app.test( - .DELETE, showPath, - loggedInRequest: false, - afterResponse: { response in - XCTAssertEqual(response.status, .noContent) - }) + @Test + func delete() async throws { + try await withTestApp { app in + let techLevel = try await BattleTech.TechLevel.create(on: app.db) + let showPath = "\(path)/\(techLevel.id!)" + + try await app.test( + .DELETE, showPath, + loggedInRequest: false, + afterResponse: { response in + #expect(response.status == .noContent) + }) + } } - func testAmmo() async throws { - let ammo = try await BattleTech.Ammo.create(on: app.db) - let showPath = "\(path)/\(ammo.$techLevelStatic.id)/ammo" - - try app.test( - .GET, showPath, - loggedInRequest: false, - afterResponse: { response in - let returnedAmmo = try response.content.decode(Page.self) - XCTAssertEqual(1, returnedAmmo.metadata.total) - }) + @Test + func ammo() async throws { + try await withTestApp { app in + let ammo = try await BattleTech.Ammo.create(on: app.db) + let showPath = "\(path)/\(ammo.$techLevelStatic.id)/ammo" + + try await app.test( + .GET, showPath, + loggedInRequest: false, + afterResponse: { response in + let returnedAmmo = try response.content.decode(Page.self) + #expect(1 == returnedAmmo.metadata.total) + }) + } } - func testEquipment() async throws { - let equipment = try await BattleTech.Equipment.create(on: app.db) - let showPath = "\(path)/\(equipment.$techLevelStatic.id)/equipment" - - try app.test( - .GET, showPath, - loggedInRequest: false, - afterResponse: { response in - let returnedEquipment = try response.content.decode(Page.self) - XCTAssertEqual(1, returnedEquipment.metadata.total) - }) + @Test + func equipment() async throws { + try await withTestApp { app in + let equipment = try await BattleTech.Equipment.create(on: app.db) + let showPath = "\(path)/\(equipment.$techLevelStatic.id)/equipment" + + try await app.test( + .GET, showPath, + loggedInRequest: false, + afterResponse: { response in + let returnedEquipment = try response.content.decode(Page.self) + #expect(1 == returnedEquipment.metadata.total) + }) + } } - func testWeapons() async throws { - let weapon = try await BattleTech.Weapon.create(on: app.db) - let showPath = "\(path)/\(weapon.$techLevelStatic.id)/weapons" - - try app.test( - .GET, showPath, - loggedInRequest: false, - afterResponse: { response in - let returnedWeapons = try response.content.decode(Page.self) - XCTAssertEqual(1, returnedWeapons.metadata.total) - }) + @Test + func weapons() async throws { + try await withTestApp { app in + let weapon = try await BattleTech.Weapon.create(on: app.db) + let showPath = "\(path)/\(weapon.$techLevelStatic.id)/weapons" + + try await app.test( + .GET, showPath, + loggedInRequest: false, + afterResponse: { response in + let returnedWeapons = try response.content.decode(Page.self) + #expect(1 == returnedWeapons.metadata.total) + }) + } } } diff --git a/Tests/AppTests/Controllers/BattleTech/WeaponsControllerTests.swift b/Tests/AppTests/Controllers/BattleTech/WeaponsControllerTests.swift index 9b58d91..27340cf 100644 --- a/Tests/AppTests/Controllers/BattleTech/WeaponsControllerTests.swift +++ b/Tests/AppTests/Controllers/BattleTech/WeaponsControllerTests.swift @@ -5,98 +5,95 @@ // import Fluent -import XCTVapor +import Testing +import VaporTesting @testable import App -final class WeaponsControllerTests: XCTestCase { - var path = "/battletech/weapons" - var app: Application! - - override func setUp() async throws { - self.app = try await Application.make(.testing) - try await configure(app) - try await app.autoMigrate() - } - - override func tearDown() async throws { - try await app.autoRevert() - try await self.app.asyncShutdown() - self.app = nil - } - - func testIndex() async throws { - _ = try await BattleTech.Weapon.create(on: app.db) - let weaponCount = try await BattleTech.Weapon.query(on: app.db).count() - - try app.test( - .GET, path, - loggedInRequest: false, - afterResponse: { response in - let weapons = try response.content.decode(Page.self) - XCTAssertEqual(weapons.metadata.total, weaponCount) - }) +@Suite(.serialized, .databaseSerialized) +struct WeaponsControllerTests { + let path = "/battletech/weapons" + + @Test + func index() async throws { + try await withTestApp { app in + _ = try await BattleTech.Weapon.create(on: app.db) + let weaponCount = try await BattleTech.Weapon.query(on: app.db).count() + + try await app.test( + .GET, path, + loggedInRequest: false, + afterResponse: { response in + let weapons = try response.content.decode(Page.self) + #expect(weapons.metadata.total == weaponCount) + }) + } } - func testShow() async throws { - let weapon = try await BattleTech.Weapon.create(on: app.db) - let showPath = "\(path)/\(weapon.id!)" - - try app.test( - .GET, showPath, - loggedInRequest: false, - afterResponse: { response in - let returnedWeapon = try response.content.decode(BattleTech.Weapon.self) - XCTAssertEqual(weapon.name, returnedWeapon.name) - }) + @Test + func show() async throws { + try await withTestApp { app in + let weapon = try await BattleTech.Weapon.create(on: app.db) + let showPath = "\(path)/\(weapon.id!)" + + try await app.test( + .GET, showPath, + loggedInRequest: false, + afterResponse: { response in + let returnedWeapon = try response.content.decode(BattleTech.Weapon.self) + #expect(weapon.name == returnedWeapon.name) + }) + } } - func testShowNotFound() async throws { - let notFoundPath = "\(path)/NOT-A-UUID" - - try app.test( - .GET, notFoundPath, - loggedInRequest: false, - afterResponse: { response in - XCTAssertEqual(response.status, .notFound) - }) + @Test + func showNotFound() async throws { + try await withTestApp { app in + let notFoundPath = "\(path)/NOT-A-UUID" + + try await app.test( + .GET, notFoundPath, + loggedInRequest: false, + afterResponse: { response in + #expect(response.status == .notFound) + }) + } } - func testDelete() async throws { - let weapon = try await BattleTech.Weapon.create(on: app.db) - let showPath = "\(path)/\(weapon.id!)" - - try app.test( - .DELETE, showPath, - loggedInRequest: false, - afterResponse: { response in - XCTAssertEqual(response.status, .noContent) - }) + @Test + func delete() async throws { + try await withTestApp { app in + let weapon = try await BattleTech.Weapon.create(on: app.db) + let showPath = "\(path)/\(weapon.id!)" + + try await app.test( + .DELETE, showPath, + loggedInRequest: false, + afterResponse: { response in + #expect(response.status == .noContent) + }) + } } - func testImport() async throws { - let (testFileHandle, testFileRegion) = try await app.fileio.openFile( - path: "Tests/Resources/BattleTech/weapons.csv", - eventLoop: app.eventLoopGroup.next() - ).get() - - let testFileByteBuffer = try await app.fileio.read( - fileRegion: testFileRegion, allocator: .init()) - let weaponsMassImport = WeaponMassImport( - file: File(data: testFileByteBuffer, filename: "weapons.csv")) - try testFileHandle.close() - - let massImportPath = "\(path)/import" - - try app.test( - .POST, massImportPath, - loggedInRequest: false, - beforeRequest: { request in - try request.content.encode(weaponsMassImport) - }, - afterResponse: { response in - XCTAssertEqual(response.status, .created) - XCTAssertNotNil(app.queues.queue.pop()) - }) + @Test + func `import`() async throws { + try await withTestApp { app in + let testFileByteBuffer = try await TestResources.buffer(for: "BattleTech/weapons.csv") + let weaponsMassImport = WeaponMassImport( + file: File(data: testFileByteBuffer, filename: "weapons.csv")) + + let massImportPath = "\(path)/import" + + try await app.test( + .POST, massImportPath, + loggedInRequest: false, + beforeRequest: { request in + try request.content.encode(weaponsMassImport) + }, + afterResponse: { response in + #expect(response.status == .created) + #expect(app.queues.queue.pop() != nil) + }) + } } } diff --git a/Tests/AppTests/Controllers/ServersControllerTests.swift b/Tests/AppTests/Controllers/ServersControllerTests.swift new file mode 100644 index 0000000..db38726 --- /dev/null +++ b/Tests/AppTests/Controllers/ServersControllerTests.swift @@ -0,0 +1,175 @@ +// +// ServersControllerTests.swift +// + +import Fluent +import Testing +import VaporTesting + +@testable import App + +@Suite(.serialized, .databaseSerialized) +struct ServersControllerTests { + let path = "/servers/announce" + + @Test + func createWithNoKeyCreatesNewServer() async throws { + try await withTestApp { app in + let dto = MegaMek.ServerDTO( + port: 2346, + version: "0.50.1", + phase: "lobby", + passworded: true, + users: ["Alice", "Bob"], + motd: "Welcome!", + key: nil + ) + + var returnedKey = "" + + try await app.test( + .POST, path, + loggedInRequest: false, + beforeRequest: { request in + request.headers.replaceOrAdd(name: .xForwardedFor, value: "203.0.113.10") + try request.content.encode(dto, as: .json) + }, + afterResponse: { response in + #expect(response.status == .ok) + returnedKey = try response.content.decode(String.self) + #expect(!returnedKey.isEmpty) + }) + + let servers = try await MegaMek.Server.query(on: app.db).all() + #expect(servers.count == 1) + + let server = try #require(servers.first) + #expect(server.serverKey == returnedKey) + #expect(server.ipAddress == "203.0.113.10") + #expect(server.port == 2346) + #expect(server.version == "0.50.1") + #expect(server.phase == "lobby") + #expect(server.passworded == true) + #expect(server.users == "Alice, Bob") + #expect(server.motd == "Welcome!") + } + } + + @Test + func createWithUnknownKeyCreatesNewServer() async throws { + try await withTestApp { app in + let dto = MegaMek.ServerDTO( + port: 2346, + version: "0.50.1", + phase: nil, + passworded: nil, + users: [], + motd: nil, + key: "does-not-exist" + ) + + var returnedKey = "" + + try await app.test( + .POST, path, + loggedInRequest: false, + beforeRequest: { request in + request.headers.replaceOrAdd(name: .xForwardedFor, value: "203.0.113.11") + try request.content.encode(dto, as: .json) + }, + afterResponse: { response in + #expect(response.status == .ok) + returnedKey = try response.content.decode(String.self) + }) + + #expect(returnedKey != "does-not-exist") + + let servers = try await MegaMek.Server.query(on: app.db).all() + #expect(servers.count == 1) + + let server = try #require(servers.first) + #expect(server.passworded == false) + #expect(server.phase == "") + #expect(server.motd == nil) + } + } + + @Test + func createWithExistingKeyUpdatesServer() async throws { + try await withTestApp { app in + let existing = try await MegaMek.Server.create( + port: 2346, + ipAddress: "198.51.100.1", + passworded: false, + users: "OldUser", + version: "0.49.0", + phase: "lobby", + motd: nil, + on: app.db) + + let dto = MegaMek.ServerDTO( + port: 2347, + version: "0.50.1", + phase: "in_progress", + passworded: true, + users: ["NewUser1", "NewUser2"], + motd: "Updated MOTD", + key: existing.serverKey + ) + + var returnedKey = "" + + try await app.test( + .POST, path, + loggedInRequest: false, + beforeRequest: { request in + request.headers.replaceOrAdd(name: .xForwardedFor, value: "203.0.113.12") + try request.content.encode(dto, as: .json) + }, + afterResponse: { response in + #expect(response.status == .ok) + returnedKey = try response.content.decode(String.self) + }) + + #expect(returnedKey == existing.serverKey) + + let servers = try await MegaMek.Server.query(on: app.db).all() + #expect(servers.count == 1) + + let fetchedServer = try await MegaMek.Server.find(existing.id, on: app.db) + let updated = try #require(fetchedServer) + #expect(updated.ipAddress == "203.0.113.12") + #expect(updated.port == 2347) + #expect(updated.version == "0.50.1") + #expect(updated.phase == "in_progress") + #expect(updated.passworded == true) + #expect(updated.users == "NewUser1, NewUser2") + #expect(updated.motd == "Updated MOTD") + } + } + + @Test + func createWithoutClientIPReturnsBadRequest() async throws { + try await withTestApp { app in + let dto = MegaMek.ServerDTO( + port: 2346, + version: "0.50.1", + phase: nil, + passworded: nil, + users: [], + motd: nil, + key: nil + ) + + try await app.test( + .POST, path, + loggedInRequest: false, + beforeRequest: { request in + try request.content.encode(dto, as: .json) + }, + afterResponse: { response in + #expect(response.status == .badRequest) + }) + } + } +} diff --git a/Tests/AppTests/DatabaseSerializedTrait.swift b/Tests/AppTests/DatabaseSerializedTrait.swift new file mode 100644 index 0000000..30122b6 --- /dev/null +++ b/Tests/AppTests/DatabaseSerializedTrait.swift @@ -0,0 +1,57 @@ +// +// DatabaseSerializedTrait.swift +// +// Every suite in this target migrates and reverts the full schema of a single, +// shared Postgres database around each test. Swift Testing schedules suites and +// their tests concurrently by default, which races those migrations against each +// other. This trait forces every test tagged with it to run one at a time, +// process-wide, regardless of which suite it belongs to. +// + +import Testing + +actor DatabaseTestLock { + static let shared = DatabaseTestLock() + + private var isLocked = false + private var waiters: [CheckedContinuation] = [] + + func acquire() async { + if !isLocked { + isLocked = true + return + } + await withCheckedContinuation { continuation in + waiters.append(continuation) + } + } + + func release() { + if waiters.isEmpty { + isLocked = false + } else { + waiters.removeFirst().resume() + } + } +} + +struct DatabaseSerializedTrait: SuiteTrait, TestTrait, TestScoping { + func provideScope( + for test: Test, + testCase: Test.Case?, + performing function: @Sendable () async throws -> Void + ) async throws { + await DatabaseTestLock.shared.acquire() + do { + try await function() + } catch { + await DatabaseTestLock.shared.release() + throw error + } + await DatabaseTestLock.shared.release() + } +} + +extension Trait where Self == DatabaseSerializedTrait { + static var databaseSerialized: Self { DatabaseSerializedTrait() } +} diff --git a/Tests/AppTests/Jobs/AmmoImportJobTests.swift b/Tests/AppTests/Jobs/AmmoImportJobTests.swift index df683c9..9e08514 100644 --- a/Tests/AppTests/Jobs/AmmoImportJobTests.swift +++ b/Tests/AppTests/Jobs/AmmoImportJobTests.swift @@ -6,46 +6,40 @@ import Fluent import Queues -import XCTVapor +import Testing +import VaporTesting @testable import App -final class AmmoImportJobTests: XCTestCase { - var app: Application! - - override func setUp() async throws { - self.app = try await Application.make(.testing) - try await configure(app) - try await app.autoMigrate() - } - - override func tearDown() async throws { - try await app.autoRevert() - try await self.app.asyncShutdown() - self.app = nil - } - - func testAmmoJob() async throws { - let job = AmmoImportJob() - let context = QueueContext( - queueName: .init(string: "test"), configuration: .init(), application: app, - logger: app.logger, on: app.eventLoopGroup.any()) - let csvRow = Importers.AmmoCSVRow(row: data()) - try await job.dequeue(context, csvRow) - let count = try await BattleTech.Ammo.query(on: app.db).count() - XCTAssertEqual(count, 1) +@Suite(.serialized, .databaseSerialized) +struct AmmoImportJobTests { + @Test + func ammoJob() async throws { + try await withTestApp { app in + let job = AmmoImportJob() + let context = QueueContext( + queueName: .init(string: "test"), configuration: .init(), application: app, + logger: app.logger, on: app.eventLoopGroup.any()) + let csvRow = Importers.AmmoCSVRow(row: data()) + try await job.dequeue(context, csvRow) + let count = try await BattleTech.Ammo.query(on: app.db).count() + #expect(count == 1) + } } - func testAmmoJobDuplicateRun() async throws { - let job = AmmoImportJob() - let context = QueueContext( - queueName: .init(string: "test"), configuration: .init(), application: app, - logger: app.logger, on: app.eventLoopGroup.any()) - let csvRow = Importers.AmmoCSVRow(row: data()) - try await job.dequeue(context, csvRow) - try await job.dequeue(context, csvRow) - let count = try await BattleTech.Ammo.query(on: app.db).count() - XCTAssertEqual(count, 1) + @Test + func ammoJobDuplicateRun() async throws { + try await withTestApp { app in + let job = AmmoImportJob() + let context = QueueContext( + queueName: .init(string: "test"), configuration: .init(), application: app, + logger: app.logger, on: app.eventLoopGroup.any()) + let csvRow = Importers.AmmoCSVRow(row: data()) + try await job.dequeue(context, csvRow) + try await job.dequeue(context, csvRow) + let count = try await BattleTech.Ammo.query(on: app.db).count() + #expect(count == 1) + } } private func data() -> [String] { diff --git a/Tests/AppTests/Jobs/EquipmentImportJobTests.swift b/Tests/AppTests/Jobs/EquipmentImportJobTests.swift index 38bfdbe..9a1c086 100644 --- a/Tests/AppTests/Jobs/EquipmentImportJobTests.swift +++ b/Tests/AppTests/Jobs/EquipmentImportJobTests.swift @@ -6,46 +6,40 @@ import Fluent import Queues -import XCTVapor +import Testing +import VaporTesting @testable import App -final class EquipmentImportJobTests: XCTestCase { - var app: Application! - - override func setUp() async throws { - self.app = try await Application.make(.testing) - try await configure(app) - try await app.autoMigrate() - } - - override func tearDown() async throws { - try await app.autoRevert() - try await self.app.asyncShutdown() - self.app = nil - } - - func testEquipmentJob() async throws { - let job = EquipmentImportJob() - let context = QueueContext( - queueName: .init(string: "test"), configuration: .init(), application: app, - logger: app.logger, on: app.eventLoopGroup.any()) - let csvRow = Importers.EquipmentCSVRow(row: data()) - try await job.dequeue(context, csvRow) - let count = try await BattleTech.Equipment.query(on: app.db).count() - XCTAssertEqual(count, 1) +@Suite(.serialized, .databaseSerialized) +struct EquipmentImportJobTests { + @Test + func equipmentJob() async throws { + try await withTestApp { app in + let job = EquipmentImportJob() + let context = QueueContext( + queueName: .init(string: "test"), configuration: .init(), application: app, + logger: app.logger, on: app.eventLoopGroup.any()) + let csvRow = Importers.EquipmentCSVRow(row: data()) + try await job.dequeue(context, csvRow) + let count = try await BattleTech.Equipment.query(on: app.db).count() + #expect(count == 1) + } } - func testEquipmentJobDuplicateRun() async throws { - let job = EquipmentImportJob() - let context = QueueContext( - queueName: .init(string: "test"), configuration: .init(), application: app, - logger: app.logger, on: app.eventLoopGroup.any()) - let csvRow = Importers.EquipmentCSVRow(row: data()) - try await job.dequeue(context, csvRow) - try await job.dequeue(context, csvRow) - let count = try await BattleTech.Equipment.query(on: app.db).count() - XCTAssertEqual(count, 1) + @Test + func equipmentJobDuplicateRun() async throws { + try await withTestApp { app in + let job = EquipmentImportJob() + let context = QueueContext( + queueName: .init(string: "test"), configuration: .init(), application: app, + logger: app.logger, on: app.eventLoopGroup.any()) + let csvRow = Importers.EquipmentCSVRow(row: data()) + try await job.dequeue(context, csvRow) + try await job.dequeue(context, csvRow) + let count = try await BattleTech.Equipment.query(on: app.db).count() + #expect(count == 1) + } } private func data() -> [String] { diff --git a/Tests/AppTests/Jobs/WeaponImportJobTests.swift b/Tests/AppTests/Jobs/WeaponImportJobTests.swift index c8e5c03..e51f06f 100644 --- a/Tests/AppTests/Jobs/WeaponImportJobTests.swift +++ b/Tests/AppTests/Jobs/WeaponImportJobTests.swift @@ -6,46 +6,40 @@ import Fluent import Queues -import XCTVapor +import Testing +import VaporTesting @testable import App -final class WeaponImportJobTests: XCTestCase { - var app: Application! - - override func setUp() async throws { - self.app = try await Application.make(.testing) - try await configure(app) - try await app.autoMigrate() - } - - override func tearDown() async throws { - try await app.autoRevert() - try await self.app.asyncShutdown() - self.app = nil - } - - func testWeaponJob() async throws { - let job = WeaponImportJob() - let context = QueueContext( - queueName: .init(string: "test"), configuration: .init(), application: app, - logger: app.logger, on: app.eventLoopGroup.any()) - let csvRow = Importers.WeaponCSVRow(row: data()) - try await job.dequeue(context, csvRow) - let count = try await BattleTech.Weapon.query(on: app.db).count() - XCTAssertEqual(count, 1) +@Suite(.serialized, .databaseSerialized) +struct WeaponImportJobTests { + @Test + func weaponJob() async throws { + try await withTestApp { app in + let job = WeaponImportJob() + let context = QueueContext( + queueName: .init(string: "test"), configuration: .init(), application: app, + logger: app.logger, on: app.eventLoopGroup.any()) + let csvRow = Importers.WeaponCSVRow(row: data()) + try await job.dequeue(context, csvRow) + let count = try await BattleTech.Weapon.query(on: app.db).count() + #expect(count == 1) + } } - func testWeaponJobDuplicateRun() async throws { - let job = WeaponImportJob() - let context = QueueContext( - queueName: .init(string: "test"), configuration: .init(), application: app, - logger: app.logger, on: app.eventLoopGroup.any()) - let csvRow = Importers.WeaponCSVRow(row: data()) - try await job.dequeue(context, csvRow) - try await job.dequeue(context, csvRow) - let count = try await BattleTech.Weapon.query(on: app.db).count() - XCTAssertEqual(count, 1) + @Test + func weaponJobDuplicateRun() async throws { + try await withTestApp { app in + let job = WeaponImportJob() + let context = QueueContext( + queueName: .init(string: "test"), configuration: .init(), application: app, + logger: app.logger, on: app.eventLoopGroup.any()) + let csvRow = Importers.WeaponCSVRow(row: data()) + try await job.dequeue(context, csvRow) + try await job.dequeue(context, csvRow) + let count = try await BattleTech.Weapon.query(on: app.db).count() + #expect(count == 1) + } } private func data() -> [String] { diff --git a/Tests/AppTests/Models/MegaMek+Testable.swift b/Tests/AppTests/Models/MegaMek+Testable.swift new file mode 100644 index 0000000..257dac8 --- /dev/null +++ b/Tests/AppTests/Models/MegaMek+Testable.swift @@ -0,0 +1,36 @@ +// +// MegaMek+Testable Extension.swift +// +// Extensions to MegaMek models for testing +// + +import Fluent +import Vapor + +@testable import App + +extension MegaMek.Server { + static func create( + port: Int = 2346, + ipAddress: String = "127.0.0.1", + passworded: Bool = false, + users: String = "", + version: String = "0.50.0", + phase: String = "lobby", + motd: String? = nil, + on database: any Database + ) async throws -> MegaMek.Server { + let server = MegaMek.Server( + port: port, + ipAddress: ipAddress, + passworded: passworded, + users: users, + version: version, + phase: phase, + motd: motd + ) + + try await server.save(on: database) + return server + } +} diff --git a/Tests/AppTests/TestResources.swift b/Tests/AppTests/TestResources.swift new file mode 100644 index 0000000..0cdbfee --- /dev/null +++ b/Tests/AppTests/TestResources.swift @@ -0,0 +1,26 @@ +// +// TestResources.swift +// mul-api +// +// Created by Richard Hancock on 7/18/26. +// + +import Foundation +import NIOCore +import NIOFileSystem + +enum TestResources { + /// Tests/Resources, located relative to this source file rather than the + /// working directory, which is not the package root when Xcode runs tests. + private static let directory = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() // AppTests + .deletingLastPathComponent() // Tests + .appendingPathComponent("Resources") + + static func buffer(for name: String) async throws -> ByteBuffer { + try await ByteBuffer( + contentsOf: FilePath(directory.appendingPathComponent(name).path), + maximumSizeAllowed: .mebibytes(1) + ) + } +} diff --git a/VERSION b/VERSION index be14282..7d85683 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.5.3 +0.5.4 diff --git a/ansible/production.yml b/ansible/production.yml index 8b93b30..c27ffe4 100644 --- a/ansible/production.yml +++ b/ansible/production.yml @@ -10,7 +10,7 @@ proxy_server_url: "localhost:8080" cert_name: "quartermaster-command.services" docker_image: "main" - docker_url: registry.tapenvy.us:443/open-source/megamek/mul-api/{{ docker_image }}:latest + docker_url: "ghcr.io/megamek/api-data:{{ docker_image }}" username: "{{ lookup('file', './templates/username') }}" password: "{{ lookup('file', './templates/password') }}" database_host: "{{ lookup('file', './templates/database_host') }}" @@ -43,7 +43,7 @@ - name: Log into private registry and force re-authorization community.docker.docker_login: - registry_url: registry.tapenvy.us:443 + registry_url: ghcr.io username: "{{ username }}" password: "{{ password }}" reauthorize: true @@ -80,12 +80,12 @@ - name: Log out of Registry community.docker.docker_login: - registry_url: registry.tapenvy.us:443 + registry_url: ghcr.io state: absent - name: Copy Nginx conf ansible.builtin.template: - src: templates/mm-api.j2 + src: templates/nginx.j2 dest: /etc/nginx/sites-available/mm-api mode: "0400" become: true @@ -96,7 +96,7 @@ dest: /etc/nginx/sites-enabled/mm-api state: link become: true - notify: reload nginx + notify: Reload nginx handlers: - name: Reload nginx diff --git a/ansible/staging.yml b/ansible/staging.yml index b3a9810..44e31d7 100644 --- a/ansible/staging.yml +++ b/ansible/staging.yml @@ -10,7 +10,7 @@ proxy_server_url: "localhost:9080" cert_name: "battletech.dev" docker_image: "develop" - docker_url: registry.tapenvy.us:443/open-source/megamek/mul-api/{{ docker_image }}:latest + docker_url: "ghcr.io/megamek/api-data:{{ docker_image }}" username: "{{ lookup('file', './templates/username') }}" password: "{{ lookup('file', './templates/password') }}" database_host: "{{ lookup('file', './templates/database_host') }}" @@ -18,7 +18,6 @@ database_password: "{{ lookup('file', './templates/database_password') }}" database_username: "{{ lookup('file', './templates/database_username') }}" sendgrid_api_key: "{{ lookup('file', './templates/sendgrid') }}" - redis_url: "{{ lookup('file', './templates/redis_url') }}" tasks: - name: Install Docker @@ -44,7 +43,7 @@ - name: Log into private registry and force re-authorization community.docker.docker_login: - registry_url: registry.tapenvy.us:443 + registry_url: ghcr.io username: "{{ username }}" password: "{{ password }}" reauthorize: true @@ -62,7 +61,6 @@ DATABASE_PASSWORD: "{{ database_password }}" DATABASE_NAME: "{{ database_name }}" SENDGRID_API_KEY: "{{ sendgrid_api_key }}" - REDIS_URL: "{{ redis_url }}" - name: Start Container community.docker.docker_container: @@ -81,11 +79,10 @@ DATABASE_PASSWORD: "{{ database_password }}" DATABASE_NAME: "{{ database_name }}" SENDGRID_API_KEY: "{{ sendgrid_api_key }}" - REDIS_URL: "{{ redis_url }}" - name: Log out of Registry community.docker.docker_login: - registry_url: registry.tapenvy.us:443 + registry_url: ghcr.io state: absent - name: Copy Nginx conf diff --git a/changelog/0.5.4.md b/changelog/0.5.4.md new file mode 100644 index 0000000..34566a6 --- /dev/null +++ b/changelog/0.5.4.md @@ -0,0 +1,3 @@ +# 0.5.4 + +- Updated to Swift 6.2 diff --git a/docker-compose.yml b/docker-compose.yml index 8e84c76..3bd1bfe 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -61,7 +61,7 @@ services: deploy: replicas: 0 db: - image: postgres:17-alpine + image: postgres:18-alpine volumes: - db_data:/var/lib/postgresql/data/pgdata environment: diff --git a/scripts/code_coverage.sh b/scripts/code_coverage.sh deleted file mode 100755 index 6375f7b..0000000 --- a/scripts/code_coverage.sh +++ /dev/null @@ -1,13 +0,0 @@ -#!/bin/bash - -swift test --enable-code-coverage - -BUILD_BIN_PATH=`swift build --show-bin-path` -# CODE_COV_PATH=`swift test --show-codecov-path > test.output && tail -n 1 test.output` - -# PROF_DATA_PATH="${CODE_COV_PATH%/*}/default.profdata" - -XCTEST_PATH="$(find ${BUILD_BIN_PATH} -name '*.xctest')" -IGNORE_FILENAME_REGEX="(\.build|TestUtils|Tests)" - -llvm-cov report $XCTEST_PATH --format=text --instr-profile=".build/debug/codecov/default.profdata" --ignore-filename-regex="$IGNORE_FILENAME_REGEX"